Repository navigation
Releases: leonardopottmayer/tars
Release list
v0.0.19
tars v0.0.18
v0.0.18
A security hardening release. It applies the fixes from a security review of the framework, mostly in the Security/Identity family: optional rate limiting on the sign-in endpoints, atomic refresh token rotation, hashed magic link tokens, stricter JWT validation, open-redirect protection on returnUrl, a timing-safe hook for unknown users, and a context-bound ISecretProtector. The Telegram client stops leaking the bot token into logs and traces, and MailKit moves off versions with known vulnerabilities.
All packages are published at 0.0.18 (lockstep versioning). The packages that change are the Security packages (Identity.Abstractions, Identity, Identity.AspNetCore, DataProtection.Abstractions, DataProtection), Communication.Telegram, Communication.Email.MailKit, Observability.Abstractions, Observability.AspNetCore, Core.Primitives and Caching.Redis. Every other package is republished at 0.0.18 for version alignment.
Highlights
Identity
-
Optional rate limiting on the anonymous endpoints.
IdentityEndpointsOptions.RateLimitingPolicyattaches a named ASP.NET Core rate limiting policy to password and API key sign-in, magic link request and consume, refresh and the OAuth challenge. Tars only attaches the policy; the application defines the limits with the nativeAddRateLimiter/UseRateLimiter.null(the default) applies none.builder.Services.AddRateLimiter(o => o.AddPolicy("identity-auth", http => RateLimitPartition.GetFixedWindowLimiter(http.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new FixedWindowRateLimiterOptions { PermitLimit = 10, Window = TimeSpan.FromMinutes(1) }))); // "Tars:Security:Identity:Endpoints:RateLimitingPolicy": "identity-auth"
-
Atomic refresh token consumption.
With rotation on, two concurrent refreshes with the same token could both succeed.InMemoryRefreshTokenStorenow removes with a compare-and-remove, and theIRefreshTokenStore.GetAndRemoveAsynccontract requires persistent stores to be atomic too (e.g.DELETE ... RETURNING, RedisGETDEL). -
Magic link tokens are stored hashed.
The store now receives only the SHA-256 hash of each token, so reading the store (a database table) no longer lets anyone sign in. The link still carries the raw token. -
Stricter JWT validation.
- Validation accepts only HS256, the algorithm the issuer signs with, in both
JwtTokenValidatorand the ASP.NET CoreJwtBearersetup. - A
SigningKeyshorter than 32 bytes (UTF-8) now stops the host at startup instead of failing at the first token issuance. - The options validation messages now name the rule that failed (
SecurityIdentityOptions,IdentityAspNetCoreOptions,DataProtectionOptions).
- Validation accepts only HS256, the algorithm the issuer signs with, in both
-
returnUrlcannot be an open redirect.
The magic link request and the OAuth challenge accept a local path (/dashboard, but not//hostor/\host) or an absolute URL whose origin is listed in the newEndpoints:AllowedReturnUrlOrigins. Anything else returns400. -
Timing-safe hook for unknown users.
PasswordAuthenticatorBasecalls the newprotected virtual VerifyPasswordForMissingUserAsyncwhen no user matches. Override it to run your hasher against a fixed dummy hash, so an unknown username takes as long as a wrong password. The default does nothing, since the hasher belongs to the app. -
Refresh cookie follows the configured lifetime.
The refresh token cookie now expires withRefreshToken.Lifetimeinstead of a fixed 7 days. The new optionalCookie:RefreshTokenCookiePathscopes it to, say,/identity, so only the refresh and sign-out endpoints receive it. Sign-out deletes each cookie on the path it was written with. -
Smaller fixes.
- The OAuth challenge redirects to the configured
Endpoints:OAuthCallbackPathinstead of the default one. - A one-time startup warning appears when no
ITokenRevocationServiceis registered (revoked tokens otherwise stay valid until they expire). - The docs now warn against reading API keys from the query string.
- The OAuth challenge redirects to the configured
DataProtection: context-bound secrets
ISecretProtectorgainsProtect(plaintext, context)andUnprotect(ciphertext, context). InAesGcmSecretProtectorthe context (e.g. table, column and row id) is AES-GCM associated data, so a ciphertext copied into another record fails authentication instead of decrypting there. The one-argument methods use an empty context, which equals no associated data: values already stored keep decrypting.
Telegram: the bot token no longer leaks into logs and traces
- The Bot API carries the bot token in the URL path. The Telegram
HttpClientnow logs requests with the token masked (POST https://api.telegram.org/bot***/sendMessage responded 200 ...) instead of the default request logging. The masked URL also rides on the request asTarsTelemetry.RedactedRequestUrl, whichAddTarsHttpClientTracingwrites to the span'surl.full. Spans are kept, just masked. - On by default.
Tars:Communication:Telegram:RedactBotToken: falseturns it off. - A host that calls OpenTelemetry's
AddHttpClientInstrumentationdirectly can apply the same enrich (seedocs/communication/telegram.md).
Dependencies
- MailKit
4.13.0→4.18.2clears the vulnerable transitive MimeKit andSystem.Security.Cryptography.Pkcsversions (GHSA-846f-f764-76mx, GHSA-2fmv-rv2c-59x7, GHSA-g7hc-96xr-gvvx, GHSA-9j88-vvj5-vhgr). Core.Primitivesno longer referencesSystem.Text.Json 10.0.0-preview.5. Every package built on it carried a preview dependency. It now uses the framework'sSystem.Text.Json.
Compatibility
- Breaking for custom
ISecretProtectorimplementations. They must implement the two new overloads with a context. - Magic links issued before the upgrade stop working once you upgrade, because the store now keys by hash. They are short-lived (15 minutes by default). Custom
IMagicLinkTokenStoreimplementations need no code change: same signature, the parameter now carries the hash. - Custom persistent
IRefreshTokenStoreimplementations should makeGetAndRemoveAsyncatomic. - Behaviour changes that can surface on upgrade:
- a
SigningKeyunder 32 bytes now fails at startup; - an absolute
returnUrlnow returns400unless its origin is inAllowedReturnUrlOrigins; - JWTs signed with anything but HS256 are rejected (Tars only issues HS256).
- a
- Recommended: override
VerifyPasswordForMissingUserAsyncin yourPasswordAuthenticatorBase. - Telegram: request logs use new message templates under
System.Net.Http.HttpClient.Tars.Communication.Telegram, and the package now depends onObservability.Abstractions(no dependencies of its own). - Recompile against
0.0.18. Some public constructors gained optional parameters (e.g.JwtTokenValidator).
CI
- Both workflows declare
permissions: contents: read, andpack.ymlpins its actions to commit hashes.
Docs
endpoints.md(rate limiting, revocation warning),configuration.md(refresh cookie path, return URL origins, signing key minimum, API key query string risk),oauth.md,authentication-flows.md(dummy-hash override),overview.md,scenarios.md(atomic Redis store),telegram.mdand the Communication and Observability configuration docs.
Tests
- The unit suites are green at 543 tests. The Security suite went from 29 to 61, with new tests for each fix. Each one fails on the code before the fix.
- The Telegram span test runs a real
HttpClientcall against a local Kestrel server, since .NET only creates the span on a real network call.
Full Changelog: v0.0.17...v0.0.18
tars v0.0.17
v0.0.17
A small, additive release in the Communication family: Telegram albums. An incoming message now says which album it belongs to, so a caller can carry the album's single caption across every file in it.
All packages are published at 0.0.17 (lockstep versioning). Only the Telegram packages change (Communication.Telegram.Abstractions, Communication.Telegram). Every other package is republished at 0.0.17 for version alignment.
Highlights
Media group id on incoming messages
-
Albums are recognisable.
Several photos or documents sent together arrive from the Bot API as one message each, and only one of them (the first, from the official apps) carries the caption.TelegramIncomingMessagegains an optionalMediaGroupId, mapped from the Bot API'smedia_group_id, so a caller can group those messages and apply the caption to every file.if (message.MediaGroupId is { } album) captions.TryAdd(album, message.Caption); // carry the album's caption to its other files
Compatibility
- Source-compatible for normal use. Messages that are not part of an album carry
MediaGroupId = null, and nothing else in the contract changes. - Recompile against
0.0.17.TelegramIncomingMessageis a positional record, so its constructor andDeconstructgained a parameter. Binaries compiled against0.0.16must be rebuilt. Code that deconstructs it into variables needs one more (_).
Docs
docs/communication/telegram.md: the Media section explains how albums arrive and that only one message carries the caption.
Tests
- New wire-mapper test:
media_group_idis carried toTelegramIncomingMessage.MediaGroupId. The Communication unit suite is green at 126 tests.
Full Changelog: v0.0.16...v0.0.17
tars v0.0.16
v0.0.16
A small, additive release in the AI family: multimodal chat input. A user turn can now carry binary content (a voice note, a photo) alongside its text. Transcribing audio or reading an image becomes an ordinary chat completion: no new capability package, no separate transcription client.
All packages are published at 0.0.16 (lockstep versioning). Only the AI chat packages change (Ai.Chat.Abstractions, Ai.Chat.Gemini). Every other package is republished at 0.0.16 for version alignment.
Highlights
Multimodal input on the chat contract (ChatAttachment)
-
Binary content travels with the user turn.
The newChatAttachment(ReadOnlyMemory<byte> Data, string MimeType)inPottmayer.Tars.Ai.Chat.Abstractionspairs the bytes with the MIME type the provider needs to read them (audio/ogg,image/jpeg, …).ChatMessagegains an optionalAttachmentslist and aChatMessage.User(content, params attachments)factory, so a caller builds a multimodal turn the same way it builds a text one. -
Transcription is just a completion.
Sending a voice note with a "transcribe this verbatim" instruction and no tools returns the transcript ascompletion.Message.Content. A caller that wants speech → action runs two calls: transcribe, then feed the transcript to its normal tool-calling pipeline. The spoken utterance is then logged and echoed as text, and everything downstream stays identical to typed input.var transcription = await client.CompleteAsync(new ChatRequest( "gemini-2.5-flash", [ChatMessage.User("Transcribe this voice note verbatim.", new ChatAttachment(audioBytes, "audio/ogg"))], Temperature: 0), ct);
Gemini maps attachments to inlineData
- Each attachment becomes an inline part after the text.
Pottmayer.Tars.Ai.Chat.Geminisends everyChatAttachmentas aninlineDatapart (MIME type + base64 data) following the turn's text part. Turns without attachments serialize exactly as before. - Inline means small. Gemini caps an inline request at 20 MB, so callers should bound what they attach. For example, Pandora's assistant refuses voice notes over 5 MB before calling the provider.
Compatibility
- Source-compatible for normal use. Existing
new ChatMessage(role, content, toolCalls)calls and theChatMessage.System/ChatMessage.User(content)factories are unchanged. Providers ignoreAttachmentswhen it isnull. - Recompile against
0.0.16.ChatMessageis a positional record, so its constructor andDeconstructgained a fourth parameter. Binaries compiled against0.0.15must be rebuilt. Code that deconstructs aChatMessageinto three variables needs a fourth (_).
Docs
- New "Multimodal input" section in
docs/ai/overview.md, with the transcription example and the inline-size caveat.ChatAttachmentis listed among the main contracts.
Tests
- The
Ai.Chatunit suite is green at 34 tests. A new wire-mapper test asserts that an attachment is sent as aninlineDatapart after the text, with the MIME type preserved and the bytes base64-roundtripped. - First consumer: Pandora's Assistant uses it for Telegram voice notes (download → transcribe → interpret), covered by that repo's unit tests. It has not yet been validated against the live Gemini API.
Full Changelog: v0.0.15...v0.0.16
tars v.0.0.15
v0.0.15
A new axis in the data family: a MongoDB document provider that implements the
same provider-agnostic contracts as the relational axis, so application and domain
code — repository interfaces, units of work, handlers — is identical whether a database
key is backed by PostgreSQL or MongoDB. To get there, the shared runtime was extracted
into a neutral base package that neither provider leaks into.
All packages are published at 0.0.15 (lockstep versioning). The data family changes
(new Data, Data.Document.Abstractions, Data.Document.MongoDB; reshaped
Data.Abstractions, Data.Relational.Abstractions, Data.Relational); every other
package is republished at 0.0.15 for version alignment.
Highlights
MongoDB document provider (same contracts, no IMongoRepository)
-
One repository contract for every backend.
IStandardRepository<TEntity, TKey>is expressed entirely in BCL types
(Expression<Func<T, bool>>,QueryParams), so MongoDB implements it exactly as EF
Core does. A domain interface stays neutral (IProductRepository : IStandardRepository<Product, string>);
switching a key from relational to MongoDB means changing which pipeline owns the key
and which concrete repository is registered — never the domain interface. The
relational-only surface (Queryable(), raw EFIQueryable) moved to a separate
IRelationalRepository, opted into only when composable LINQ is truly needed. -
A real unit of work with read-your-writes and atomic commit.
MongoDataContextruns every read and write through a singleIClientSessionHandle
with an open transaction, started lazily on first use: reads see the unit's own
uncommitted writes,CommitAsyncflushes them atomically, and disposing without
committing aborts. Domain events are collected explicitly (no change tracker) via
Track()/CollectDomainEventsand dispatched inside the transaction, before
commit, so an aggregate change and its integration event commit together or not at
all. Transactions require a replica set (a single-node one is enough for dev). -
Its own connection resolution, mirroring the relational precedence.
A dedicatedIMongoConnectionResolverreadsTars:Data:Mongo:*— per-tenant
connections, tenant templates with{tenantKey}/{tenantCode}placeholders, then
static/shared — resolving a database name alongside the connection string. As with
the relational axis, the app talks to the resolver interface, so the config-backed
reader is one swappable strategy (vault, tenant catalog, etc.).
Neutral base runtime (Pottmayer.Tars.Data)
-
A provider-clean shared layer.
The runtime that both providers share — context accessor, composite/keyed context
factory, unit of work, repository resolver, multi-database coordination,QueryParams
→Expressiontranslation — moved out of the relational package into a new
Pottmayer.Tars.Database that references onlyData.Abstractions+Core.Ddd+
DI (zero EF, zero MongoDB). This mirrors the Caching family's shape (Abstractions +
base runtime + provider packages). -
Any mix of providers, side by side, multitenant.
A MongoDB key and a relational key resolve through the same composite factory, so one
application can register N SQL + N Mongo keys behind a singleIUnitOfWorkFactory, each
multitenant, withIMultiDatabaseCoordinatorcoordinating a best-effort (Level 1)
cross-provider commit.
Compatibility
- Breaking for
0.0.14relational-data consumers (this release is a data-family
refactor, not purely additive):AddTarsData<TDbContext>is renamed toAddTarsRelationalData<TDbContext>, and
AddTarsRelationalUnitOfWorkFactorytoAddTarsUnitOfWorkFactory, for
provider/base naming consistency (provider prefix when the piece is provider-specific,
no prefix in the neutral base).IStandardRepository,IDataContextFactory,IKeyedDataContextFactoryand the
multi-database coordination contracts moved namespace into
Pottmayer.Tars.Data.Abstractions; consumers update theirusingdirectives.- The relational-only
Queryable()moved fromIStandardRepositoryto
IRelationalRepository; repositories that need rawIQueryableextend that
interface. - Applications must now also add a reference to the new
Pottmayer.Tars.Database
package (the shared infrastructure registrations live there).
- Everything else is unchanged. Query semantics, connection-resolution precedence,
multitenancy behavior and the coordinator are identical; the renames and moves are
mechanical.
Tests
- The
Dataunit suite is green at 30 tests, including new coverage for
multi-provider coexistence (a composite factory routing relational and Mongo keys side
by side), the context accessor, unit of work and the Mongo connection resolver. - The MongoDB provider was additionally validated end-to-end in
tars-sandbox
(Testcontainers) against a real MongoDB replica set + PostgreSQL: CRUD roundtrip,
transactional rollback, read-your-writes, domain-event dispatch, dynamic
filter/sort/paginate, database-per-tenant isolation, and a cross-provider
coordinator committing MongoDB and SQL together — 7 integration tests green.
Full Changelog: v0.0.14...v0.0.15
tars v0.0.14
v0.0.14
Two additions to the messaging family, both behind the existing
IIntegrationEventBus seam so application and domain code are untouched: publishing
across several brokers in one process, and a transactional outbox that delivers
to a broker — the piece that makes a transactional outbox on Kafka work.
All packages are published at 0.0.14 (lockstep versioning). The messaging family
changes (Messaging.Broker, Messaging.MassTransit, Messaging.MassTransit.RabbitMq,
Messaging.MassTransit.Kafka, Messaging.EntityFrameworkCore); every other package is
republished at 0.0.14 for version alignment.
Highlights
Multi-transport composite (N brokers, fan-out)
-
One process, several brokers, one seam.
ACompositeIntegrationEventBusresolves a keyed transport per event from a
declarative event→transport map, so producers keep callingPublishAsyncand never
learn a second transport exists. An event can fan out to many brokers at once;
failures across the targets are gathered into a singleAggregateException. The
transport-agnostic core (EventTransportMap,IEventTransportSelector,
CompositeIntegrationEventBus) lives inMessaging.Brokerand has no MassTransit
dependency. -
The orchestrator references no concrete broker.
AddTarsMassTransitCompositelives in the coreMessaging.MassTransitpackage and
composes whatever transports are contributed to it through theIMassTransitTransport
seam — RabbitMQ as a full/primary (or multibus) transport, Kafka as a rider. Each
transport's wiring (AddRabbitMq,AddRabbitMq<TBus>,AddKafka) ships in its own
package, so an application references only the transports it uses — a Kafka + SQS
app never drags in RabbitMQ. Adding a new transport (e.g. SQS) is one new package with
anIMassTransitTransportcontributor; the orchestrator does not change. -
Multibus is first class.
A second full transport (AddRabbitMq<TBus>(key, cfg)) becomes a MassTransit multibus
keyed on its marker interface — closure-based, no reflection. One full transport with no
marker is the primary; a second unmarked primary fails fast at startup with a pointer to
the multibus overload.
Transactional outbox delivery to a broker ("outbox on Kafka")
-
The relay's last mile is now pluggable.
The in-process outbox relay delivers throughIOutboxRelayDelivery:
LocalHandlerOutboxDelivery(the default — unchanged in-process dispatch to
IIntegrationEventHandler<T>) orBrokerOutboxDelivery, which forwards each drained
event to a keyed transport bus.AddTarsOutboxBrokerDelivery(transportKey)switches
it on. The row is still written in the producer's transaction and drained the same way —
only the destination moves from local handlers to the broker. -
The one clean way to get a transactional outbox on Kafka.
MassTransit's own bus outbox (UseBusOutbox) cannot cover Kafka, because the Kafka path
publishes throughITopicProducer<T>, not theIPublishEndpointthe bus outbox
intercepts. The relay sidesteps that: it stores at the seam and republishes through the
keyed transport bus afterwards, at-least-once, with the relay's existing retry, backoff
and dead-lettering. -
Same table, or Debezium — your choice.
Because the outbox table is a durable, transactionally-written log, an external CDC reader
(Debezium) can drain it from the PostgreSQL WAL instead of the relay — no framework
code on that path; the columns already fit Debezium's outboxEventRouter. The two are
mutually exclusive per table (both would double-publish) and differ in wire format — the
relay emits the MassTransit envelope (for your MassTransit consumers), Debezium the raw
payload (for external/raw-JSON consumers) — so a natural split is each on its own topics.
docs/messaging/outbox.mddocuments both.
Compatibility
- Non-breaking for released
0.0.13consumers. Both features are additive: the
composite APIs, the keyed provider helpers (AddTarsKeyed{RabbitMq,Kafka}IntegrationEventBus)
andAddTarsOutboxBrokerDeliveryare new. Existing single-broker and in-process-outbox
registrations behave exactly as before, with no code or configuration change. - One internal caveat: the outbox relay's public types
OutboxRelayProcessorand
OutboxRelayServicegained anIOutboxRelayDeliveryconstructor parameter in place of the
dispatcher. Applications that register the relay throughAddTarsOutboxRelayare
unaffected; only code that constructs the relay directly needs the new argument. - Broker delivery targets a keyed transport bus by design:
AddTarsOutboxBusreplaces
the ambient (unkeyed) bus with the outbox writer, so the keyed bus is the real transport
and there is no loop back into the outbox.
Tests
- The
Messagingunit suite is green at 147 tests, including new coverage for the
composite (arbitrary keys, single/zero transports, second-primary rejection with a
multibus hint, marked secondary as a keyed bus, a custom transport contributor composing
as the primary — proving the drop-in seam) and for the broker delivery (BrokerOutboxDeliveryTests:
forwards to the keyed bus; clear error when the key is unregistered). - The outbox-on-Kafka path was additionally validated end-to-end against a real Kafka
broker (Testcontainers): the relay's broker delivery publishes a drained event to a real
topic and a real consumer receives it.
Full Changelog: v0.0.13...v0.0.14
tars v0.0.13
v0.0.13
Makes the Gemini chat provider resilient to transient failures. When Gemini answers 503
("the model is overloaded") — or rate-limits with 429, or has any other bad server moment — the
call is now retried automatically with exponential backoff before the failure ever reaches the
caller, instead of surfacing as an error on the first hiccup.
All packages are published at 0.0.13 (lockstep versioning). Only Ai.Chat.Gemini changes; the
rest are republished at 0.0.13 for version alignment.
Highlights
-
Transient Gemini failures are retried, transparently.
The Gemini typedHttpClientgets a retry handler (AddTarsAiChatGeminiHttpClientnow chains a
resilience handler) that retries the transient set —503,429, other5xx, request timeouts
and network errors — with exponential backoff and jitter. Retrying sits below the client:
only the final response is classified into anAiException, soGeminiAiChatCompletionClient,
its callers, and the existing tests are untouched. -
The server's
Retry-Afterwins.
On429/503Gemini often tells you how long to wait; the handler honours that header
(ShouldRetryAfterHeader) in preference to its own computed backoff, so rate limits are respected
rather than hammered. The retry predicate reuses the framework default
(HttpClientResiliencePredicates.IsTransient), which covers exactly the408/429/5xx/timeout set
the error classifier already treats as transient — one source of truth for "what is transient". -
Configurable, and switchable off.
GeminiAiOptionsgainsMaxRetryAttempts(default3) andRetryBaseDelay(default1s), bound
fromTars:Ai:Chat:Geminiand validated inIsValid(). SettingMaxRetryAttemptsto0
disables retrying entirely — the handler adds no strategy, so it never trips Polly's
"attempts must be ≥ 1" validation. -
Built on Microsoft's resilience stack.
UsesMicrosoft.Extensions.Http.Resilience(the Microsoft-published wrapper over Polly v8), not a
hand-rolled loop.
Compatibility
- Non-breaking for
Ai.Chat.Geminiconsumers. The new options carry sensible defaults, so
existing registrations behave the same — now with retries — with no code or configuration change.
Retry is transparent: nothing about theIAiChatCompletionClientcontract changes. - One caveat: this project's
Microsoft.Extensions.*references move from10.0.2to10.0.11
to satisfy the resilience package's transitive dependencies. This is a patch-level bump within the
same10.0.xline. - Every other package is unchanged; upgrading them from
0.0.12is a version bump.
Tests
- Five DI-level resilience tests exercise the handler through the real pipeline (only the primary
HTTP handler is stubbed, counting attempts): a503retried then succeeding, a429retried,
a permanent400not retried, giving up after the configured attempts, and
MaxRetryAttempts = 0disabling retries. The Gemini suite is green at 33 tests.
Full Changelog: v0.0.12...v0.0.13
tars v0.0.12
v0.0.12
A cross-platform correctness fix for options validation. No API changes.
All packages are published at 0.0.12 (lockstep versioning).
Fix
- URL validation is now consistent across platforms.
GeminiAiOptions.IsValid()andTelegramBotOptions.IsValid()accepted a relative, leading-slash
path (e.g./relative/only) as a valid base URL on Unix, becauseUri.TryCreate(…, UriKind.Absolute, …)
parses such a path as an absolutefileURI there while rejecting it on Windows. Both now require
the scheme to be http or https, so an invalid base URL is rejected the same way on every OS.
This also fixes a CI test that passed on Windows but failed on Linux.
Compatibility
- Purely a fix; no behavior change for any valid (http/https) configuration. Upgrading from
0.0.11
is a version bump.
Tests
- The Gemini and Telegram option suites assert that a non-http(s) base URL — empty,
not-a-url, or a
relative/relative/onlypath — is invalid on all platforms.
Full Changelog: v0.0.11...v0.0.12
tars v0.0.11
v0.0.11
Turns the Telegram client into a multi-bot factory: one host can now run several bots
(e.g. one for notifications, one as an assistant gateway) behind a single contract, each with its
own token and configuration. A reserved default bot keeps single-bot apps unchanged in spirit.
All packages are published at 0.0.11 (lockstep versioning). Only Communication.Telegram and its
Abstractions change; the rest are republished at 0.0.11 for version alignment.
Highlights
-
One contract, many bots, resolved by name.
Callers depend onITelegramClientFactoryand resolve a bot with
factory.GetClient("notifications"). Bot selection is data-driven, not keyed DI: each bot is an
entry inTelegramOptions.Bots(a name →TelegramBotOptionsmap), and the factory looks it up
— adding a bot is a line of configuration, not a code change. Names resolve case-insensitively,
and an unconfigured name throws a permanentTelegramExceptionrather than failing silently. -
Per-bot configuration under one section.
TelegramOptionsbecomes a container bound fromTars:Communication:Telegram, holding aBots
dictionary; eachTelegramBotOptionscarries its ownBotToken,ApiBaseUrl,RequestTimeout
andPollTimeoutGrace.IsValid()moves per-bot: an empty set of bots is valid, but any bot
without a token — or with a non-http(s) base URL — is not. -
One method per registration.
The DI surface follows the framework convention:AddTarsTelegramOptions(binds the container,
optional custom section and configure callback),AddTarsTelegramHttpClient(the shared
HttpClientwith the handler timeout disabled for long polling), andAddTarsTelegramClientFactory
(the factory) — each does one thing, the same shape asCommunication.Email.MailKit. -
A reserved
defaultbot for single-bot apps.
ITelegramClientFactory.GetClient()with no name resolves the bot registered under
ITelegramClientFactory.DefaultBotName("default"), so an app that runs exactly one bot never
has to name it. CallingGetClient()with nodefaultconfigured throws a permanent error.
Compatibility
- Breaking for
Communication.Telegramconsumers. The options shape changes from a single-bot
object to aBotscontainer, and DI is now composed fromAddTarsTelegramOptions+
AddTarsTelegramHttpClient+AddTarsTelegramClientFactory, withITelegramClientFactoryas the
resolution entry point. Migrating is a configuration move (wrap the old values under
Bots:<name>) plus swapping the registration calls. - Every other package is unchanged; upgrading them from
0.0.10is a version bump.
Tests
- The Telegram registration/options suite is rewritten around the container/factory model: factory
resolution (by name, case-insensitive, default bot, unconfigured-bot error), per-bot binding from
configuration, the shared long-pollingHttpClient, and per-bot validation including a non-http
base URL.
Full Changelog: v0.0.10...v0.0.11
tars v0.0.10
v0.0.10
Adds an AI family: chat completion with tool calling behind one contract, with the
provider chosen per call, and a Gemini provider.
All packages are published at 0.0.10 (lockstep versioning). Only the new packages add
code; the rest are republished at 0.0.10 for version alignment.
Highlights
-
One chat-completion contract, provider chosen per call.
Callers depend only onIAiChatCompletionClient, or resolve one through
IAiChatCompletionClientFactory.GetClient("gemini"). Provider selection is keyed DI: each
provider registers its client under a service key equal to its name, and the factory looks it
up — adding a provider is a registration, not a change to callers. TheModeland the
ApiKeytravel on eachChatRequest, not as app-wide defaults, so one instance serves
whatever model a request names and a multi-user host can pass each user's own key per call
(ChatRequest.ToString()masks it). PassTemperature: 0for deterministic output. The
family is namespaced by capability (Ai.Chat.*), leaving room forAi.Transcription.*
andAi.Embedding.*later without reorganizing;AddTarsAiClientFactoryregisters the
factory separately, one method per concern. -
Tool calling with the LLM as chooser, not executor.
A request carriesToolDefinitions (name, description, and a raw JSON-Schema string for the
arguments); the reply's meaningful output isChatCompletion.ToolCalls— each a tool name
plus its arguments as raw JSON — or prose when the model called nothing. The model never
invokes anything itself: the caller validatesToolCall.Argumentsagainst the schema and
decides whether to execute. Failures surface asAiExceptionwith the one field callers act
on,IsPermanent: an unknown model, a malformed request, a missing/invalid key or an invalid
tool schema are permanent (a retry cannot help); an unreachable endpoint, a timeout or a
malformed response are transient.AiExceptionlives inAi.Abstractionsbecause it is
shared across every capability, not just chat. -
A Gemini provider that carries the key per request.
GeminiAiChatCompletionClientcallsv1beta/models/{model}:generateContent, sending the key
in thex-goog-api-keyheader (never in the URL) — fromChatRequest.ApiKeywhen present,
otherwise the configuredGeminiAiOptions.ApiKey, and throwing a permanentAiExceptionwhen
neither exists rather than reaching the endpoint unusable. It maps the shared message/tool
model to Gemini's wire shape (systemInstruction,functionDeclarations/
functionCall/functionResponse) and classifies HTTP failures into permanent vs transient.
Options bind fromTars:Ai:Chat:Gemini(AddTarsAiChatGeminiOptions, validated on start), and
registration isAddTarsAiChatGeminiHttpClient+AddTarsAiChatCompletionClientGemini— one
method per registration, the same shape asCommunication.Email.MailKitorCaching.Redis.
Compatibility
- Purely additive; no behavior or API changes to existing packages. Upgrading from
0.0.9is a
version bump.
New packages
Pottmayer.Tars.Ai.AbstractionsPottmayer.Tars.Ai.Chat.AbstractionsPottmayer.Tars.Ai.ChatPottmayer.Tars.Ai.Chat.Gemini
New dependencies
- No third-party AI SDK: the Gemini provider talks to the Generative Language API directly over
HttpClient+System.Text.Json. - The new packages add only
Microsoft.Extensions.*primitives (DI, Hosting and Configuration
abstractions,Http, and Options binding) for their DI helpers and typedHttpClient.
Tests
- 28 unit tests: the wire mapper (tools,
system→systemInstruction, function-call
round-trips), the error classifier (permanent vs transient by status), the Gemini client via
anHttpMessageHandlerstub (including per-request key override), options binding and
validation, and the keyed client factory resolution.
Full Changelog: v0.0.9...v0.0.10