From b9f6b9ee01ff6a72d87aa008f33ecd7d99f229e1 Mon Sep 17 00:00:00 2001 From: Graeme Foster <80714+GraemeF@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:11:33 +0100 Subject: [PATCH] Refuse a message body the realm would clip Zulip cuts a body past its max_message_length and still answers success, so a long post read back truncated while the sender saw it succeed. The Zulip adapter now reads the realm's limit once and fails post and edit with a typed MessageRejected before sending. The memory adapter takes a configurable limit so the shared contract suite asserts the refusal. --- packages/core/ports.ts | 33 ++++++++++++-- packages/mcp/tools.ts | 2 +- packages/memory/adapter.ts | 25 +++++++++++ packages/memory/contract.test.ts | 3 ++ packages/testing/contract.ts | 8 ++++ packages/testing/contract/helpers.ts | 6 ++- packages/testing/contract/posting.ts | 25 +++++++++++ packages/testing/contract/publisher.ts | 14 ++++++ packages/zulip/adapter.test.ts | 57 ++++++++++++++++++++++++ packages/zulip/adapter.ts | 60 ++++++++++++++++++++++++-- packages/zulip/contract.live.test.ts | 1 + packages/zulip/in-process-contract.ts | 7 ++- packages/zulip/in-process-realm.ts | 7 ++- packages/zulip/message-length.ts | 40 +++++++++++++++++ 14 files changed, 276 insertions(+), 12 deletions(-) create mode 100644 packages/zulip/message-length.ts diff --git a/packages/core/ports.ts b/packages/core/ports.ts index 90a188d..e4c46e9 100644 --- a/packages/core/ports.ts +++ b/packages/core/ports.ts @@ -543,8 +543,9 @@ export interface MessagePublisher { * `UnresolvedMention` when the body carries a mention token no identity * resolves (pre-flighted for the same reason — the substrate would post it * and notify nobody), and a `PublisherError` wrapping any other substrate - * failure. Calling before `identity.acquire` is a defect, not a typed - * failure. + * failure. A body the substrate cannot store whole fails with a typed + * `MessageRejected` rather than landing clipped. Calling before + * `identity.acquire` is a defect, not a typed failure. * * `opts.thread` names a thread by its clean name — resolution is never * encoded in the name (see `ObservedThreadSchema`). A post into a **resolved** @@ -567,7 +568,10 @@ export interface MessagePublisher { channel: ChannelName, body: MessageBody, opts?: PostOpts, - ): Effect.Effect + ): Effect.Effect< + MessageRef, + BindError | UnknownChannel | UnresolvedMention | MessageRejected | PublisherError + > /** * Replace the body of an existing message attributed to the bound * identity. Adapters target the substrate's in-place edit primitive @@ -584,7 +588,10 @@ export interface MessagePublisher { edit( message: MessageRef, body: MessageBody, - ): Effect.Effect + ): Effect.Effect< + void, + BindError | UnresolvedMention | MessageRejected | MessageEditRefused | PublisherError + > /** * Whether the substrate permits `edit` at all *right now*, for anyone — * the realm-wide switch behind `MessageEditRefused`'s `editing-disabled` @@ -919,6 +926,24 @@ export class ChannelDescriptionRejected extends Data.TaggedError('ChannelDescrip } } +/** + * Refusal of `MessagePublisher.post` or `edit` when the substrate cannot store + * the body whole. A substrate that clips an over-long body and reports success + * leaves the sender believing the whole message landed, so adapters refuse + * instead. `detail` carries the adapter's own explanation, including the actual + * limit, for a caller to show a human. Tagged so the MCP edge can report a + * fixable input apart from a substrate outage, which stays `PublisherError`. + */ +export class MessageRejected extends Data.TaggedError('MessageRejected')<{ + readonly operation: 'post' | 'edit' + readonly substrate: string + readonly detail: string +}> { + override get message(): string { + return `${this.operation} failed — ${this.substrate} rejected the message: ${this.detail}` + } +} + /** * Failure of a `MessagePublisher` write whose body carries a mention token that * resolves to no known identity — a stale `@**Full Name**` the substrate would diff --git a/packages/mcp/tools.ts b/packages/mcp/tools.ts index 9a2fd2b..5ea7c4a 100644 --- a/packages/mcp/tools.ts +++ b/packages/mcp/tools.ts @@ -727,7 +727,7 @@ const buildToolDefs = (deps: RegisterToolsDeps, cache: InternalCache): ReadonlyA { name: 'post', description: - 'Post a message to a channel by name. Optional thread (topic), mentions (identity ids the bot has seen), and reply_to (message id). To ping someone on Zulip, write the @**Name** markup inline in body where you want it rendered — the mentions array is notification metadata only and does not modify body. Returns {message_id, channel_id, channel_name, thread, permalink}. When you show a human this message afterwards, link it by the returned permalink — never a bare name or message id.', + 'Post a message to a channel by name. Optional thread (topic), mentions (identity ids the bot has seen), and reply_to (message id). To ping someone on Zulip, write the @**Name** markup inline in body where you want it rendered — the mentions array is notification metadata only and does not modify body. A body longer than the realm stores whole is refused with an error naming the limit; split it across several posts. Returns {message_id, channel_id, channel_name, thread, permalink}. When you show a human this message afterwards, link it by the returned permalink — never a bare name or message id.', inputSchema: { type: 'object', properties: { diff --git a/packages/memory/adapter.ts b/packages/memory/adapter.ts index 0b1028a..dd34ff5 100644 --- a/packages/memory/adapter.ts +++ b/packages/memory/adapter.ts @@ -69,6 +69,7 @@ import { type Emoji, HistoryError, MessagePermalinkSchema, + MessageRejected, mentionsIdentity, PublisherError, ThreadPermalinkSchema, @@ -169,9 +170,17 @@ export interface MemoryAdapterConfig { * assuming a number is the thing worth proving. */ readonly channelDescriptionLimit?: number + /** + * Longest message body this substrate will store, in characters. A longer + * body is refused with `MessageRejected` on post and edit rather than stored + * clipped. Like the description limit, the default is memory's own number + * and not Zulip's. + */ + readonly messageLengthLimit?: number } const DEFAULT_CHANNEL_DESCRIPTION_LIMIT = 512 +const DEFAULT_MESSAGE_LENGTH_LIMIT = 4096 export type MemoryAdapter = AgentComms & { /** @@ -242,6 +251,20 @@ export const memoryAdapter = (config: MemoryAdapterConfig = {}): Effect.Effect() const descriptionLimit = config.channelDescriptionLimit ?? DEFAULT_CHANNEL_DESCRIPTION_LIMIT + const messageLimit = config.messageLengthLimit ?? DEFAULT_MESSAGE_LENGTH_LIMIT + const refuseOverlongBody = ( + operation: 'post' | 'edit', + body: MessageBodyType, + ): Effect.Effect => + body.length > messageLimit + ? Effect.fail( + new MessageRejected({ + operation, + substrate: 'memory', + detail: `message is too long (limit: ${messageLimit} characters, got ${body.length})`, + }), + ) + : Effect.void const isThreadResolved = (channelId: string, threadName: string): boolean => resolvedThreadsByChannel.get(channelId)?.has(threadName) ?? false const withThreadResolution = (stored: StoredMessage): StoredMessage => @@ -603,6 +626,7 @@ export const memoryAdapter = (config: MemoryAdapterConfig = {}): Effect.Effect requireBound().pipe( + Effect.zipRight(refuseOverlongBody('edit', body)), Effect.flatMap(() => { const stored = messagesById.get(message.id) if (stored === undefined) { diff --git a/packages/memory/contract.test.ts b/packages/memory/contract.test.ts index e2b88ca..1b03401 100644 --- a/packages/memory/contract.test.ts +++ b/packages/memory/contract.test.ts @@ -7,6 +7,7 @@ import { memoryAdapter } from './adapter.ts' // is given the substrate's number the way any other substrate gives it — // nothing in the contract assumes a particular one. const MEMORY_DESCRIPTION_LIMIT = 512 +const MEMORY_MESSAGE_LIMIT = 4096 runAgentCommsContract('memory adapter', async () => { const adapter = await Effect.runPromise(memoryAdapter()) @@ -19,6 +20,7 @@ runAgentCommsContract('memory adapter', async () => { peerPost: (peer, channel, body, opts) => adapter.peerPost(peer, channel, body, opts).pipe(Effect.asVoid, Effect.orDie), channelDescriptionLimit: MEMORY_DESCRIPTION_LIMIT, + messageLengthLimit: MEMORY_MESSAGE_LIMIT, dispose: () => Effect.void, } }) @@ -40,6 +42,7 @@ runAgentCommsContract('memory adapter (allowlist mode for UnknownIdentity covera peerPost: (peer, channel, body, opts) => adapter.peerPost(peer, channel, body, opts).pipe(Effect.asVoid, Effect.orDie), channelDescriptionLimit: MEMORY_DESCRIPTION_LIMIT, + messageLengthLimit: MEMORY_MESSAGE_LIMIT, dispose: () => Effect.void, } }) diff --git a/packages/testing/contract.ts b/packages/testing/contract.ts index 7cda6fa..9bd4bb3 100644 --- a/packages/testing/contract.ts +++ b/packages/testing/contract.ts @@ -104,6 +104,14 @@ export interface ContractEnv { * substrate reports its own (Zulip 1024, memory its configured limit). */ readonly channelDescriptionLimit: number + /** + * Longest message body this substrate stores whole, in characters. The + * over-length tests build their input from it, so the contract asserts that + * a substrate refuses what it cannot hold and never what the cap happens to + * be. Zulip reports its realm's `max_message_length`, memory its configured + * limit. + */ + readonly messageLengthLimit: number /** Tear down whatever the factory stood up. Called once per test. */ readonly dispose: () => Effect.Effect } diff --git a/packages/testing/contract/helpers.ts b/packages/testing/contract/helpers.ts index fb56dfd..6917388 100644 --- a/packages/testing/contract/helpers.ts +++ b/packages/testing/contract/helpers.ts @@ -3,6 +3,7 @@ import type { BindError, ChannelName, MessageBody, + MessageRejected, PublisherError, UnknownChannel, UnresolvedMention, @@ -22,7 +23,10 @@ export const postSpaced = ( comms: AgentComms, channel: ChannelName, bodies: ReadonlyArray, -): Effect.Effect => +): Effect.Effect< + void, + BindError | UnknownChannel | UnresolvedMention | MessageRejected | PublisherError +> => Effect.forEach( bodies, (body, index) => diff --git a/packages/testing/contract/posting.ts b/packages/testing/contract/posting.ts index 93e64ec..b06cae1 100644 --- a/packages/testing/contract/posting.ts +++ b/packages/testing/contract/posting.ts @@ -6,6 +6,7 @@ import { decodeMessageBodySync, decodeThreadNameSync, type MessageRef, + MessageRejected, mentionedIdentities, UnknownChannel, } from '@commy/core/ports' @@ -18,6 +19,30 @@ const findByBody = ( ): { readonly body: string } | undefined => messages.find((m) => m.body === body) export const publisherPostingContract = (env: () => ContractEnv): void => { + // A substrate that clips an over-long body and reports success leaves the + // sender believing the whole message landed. The publisher refuses instead. + test('publisher.post refuses a body longer than the substrate stores', () => + Effect.runPromise( + Effect.gen(function* () { + const channel = yield* env().seedChannel('lobby') + const tooLong = decodeMessageBodySync('x'.repeat(env().messageLengthLimit + 1)) + const error = yield* Effect.flip(env().comms.publisher.post(channel.name, tooLong)) + expect(error).toBeInstanceOf(MessageRejected) + // Refused outright — nothing clipped was left behind. + const messages = yield* env().comms.history.readChannel(channel.name, { limit: 10 }) + expect(messages).toEqual([]) + }), + )) + test('publisher.post stores a body exactly at the substrate limit whole', () => + Effect.runPromise( + Effect.gen(function* () { + const channel = yield* env().seedChannel('lobby') + const atLimit = decodeMessageBodySync('x'.repeat(env().messageLengthLimit)) + yield* env().comms.publisher.post(channel.name, atLimit) + const messages = yield* env().comms.history.readChannel(channel.name, { limit: 10 }) + expect(messages.map((m) => m.body)).toEqual([atLimit]) + }), + )) test('publisher.post returns a MessageRef whose channel matches the destination', () => Effect.runPromise( Effect.gen(function* () { diff --git a/packages/testing/contract/publisher.ts b/packages/testing/contract/publisher.ts index 97adc69..4d93750 100644 --- a/packages/testing/contract/publisher.ts +++ b/packages/testing/contract/publisher.ts @@ -11,6 +11,7 @@ import { decodeThreadNameSync, MessagePermalinkSchema, type MessageRef, + MessageRejected, PublisherError, UnknownChannel, } from '@commy/core/ports' @@ -18,6 +19,19 @@ import { Effect, Option } from 'effect' import type { ContractEnv } from '../contract.ts' export const publisherContract = (env: () => ContractEnv): void => { + test('publisher.edit refuses a body longer than the substrate stores', () => + Effect.runPromise( + Effect.gen(function* () { + const channel = yield* env().seedChannel('lobby') + const original = decodeMessageBodySync('short') + const ref = yield* env().comms.publisher.post(channel.name, original) + const tooLong = decodeMessageBodySync('x'.repeat(env().messageLengthLimit + 1)) + const error = yield* Effect.flip(env().comms.publisher.edit(ref, tooLong)) + expect(error).toBeInstanceOf(MessageRejected) + const messages = yield* env().comms.history.readChannel(channel.name, { limit: 10 }) + expect(messages.map((m) => m.body)).toEqual([original]) + }), + )) // Resolution is a status distinct from the thread name: the name a consumer // addresses stays stable, and the flag surfaces on read as // ObservedThread.resolved. These hold on both substrates — memory tracks the diff --git a/packages/zulip/adapter.test.ts b/packages/zulip/adapter.test.ts index ea14ee8..093da36 100644 --- a/packages/zulip/adapter.test.ts +++ b/packages/zulip/adapter.test.ts @@ -23,6 +23,7 @@ import { IdentityError, MessageEditRefused, MessagePermalinkSchema, + MessageRejected, mentionedIdentities, PublisherError, ThreadPermalinkSchema, @@ -848,6 +849,23 @@ const seedSendMessage = (stub: StubHttpClient, id: number): Effect.Effect yield* stub.respond('POST', '/api/v1/messages', { body: { result: 'success', id }, }) + yield* seedMessageLimit(stub, 10_000) + }) + +// Zulip clips a body past `max_message_length` and still answers success, so +// the realm's limit is the only thing that tells an adapter a write is unsafe. +// It is read from `/register`, the one place Zulip publishes it. +const seedMessageLimit = (stub: StubHttpClient, limit: number): Effect.Effect => + Effect.gen(function* () { + yield* stub.respond('POST', '/api/v1/register', { + body: { + result: 'success', + queue_id: 'limit-queue', + last_event_id: -1, + max_message_length: limit, + }, + }) + yield* stub.respond('DELETE', '/api/v1/events', { body: { result: 'success' } }) }) // A mention the directory cannot place is put to Zulip's renderer before the @@ -873,6 +891,35 @@ effectTest( }), ) +// Zulip counts code points, not UTF-16 units: an emoji is one character to it. +effectTest('publisher.post counts the realm limit in code points', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* seedSendMessage(stub, 42) + yield* seedMessageLimit(stub, 10) + const adapter = yield* buildAdapter(stub) + yield* adapter.publisher.post(generalChannel.name, decodeMessageBodySync('😀'.repeat(10))) + const err = yield* Effect.flip( + adapter.publisher.post(generalChannel.name, decodeMessageBodySync('😀'.repeat(11))), + ) + expect(err).toBeInstanceOf(MessageRejected) + }), +) + +effectTest('publisher.post reads the realm limit once, not per post', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* seedSendMessage(stub, 42) + const adapter = yield* buildAdapter(stub) + yield* adapter.publisher.post(generalChannel.name, decodeMessageBodySync('one')) + yield* adapter.publisher.post(generalChannel.name, decodeMessageBodySync('two')) + const reads = (yield* stub.captured).filter( + (r) => r.method === 'POST' && r.url.pathname === '/api/v1/register', + ) + expect(reads).toHaveLength(1) + }), +) + effectTest('publisher.post returns a MessageRef built from the response id', () => Effect.gen(function* () { const stub = yield* makeStubHttpClient @@ -1207,6 +1254,7 @@ effectTest( () => Effect.gen(function* () { const stub = yield* makeStubHttpClient + yield* seedMessageLimit(stub, 10_000) yield* stub.respond('GET', '/api/v1/streams', { body: { result: 'success', streams: [] } }) const adapter = yield* buildAdapter(stub) const error = yield* Effect.flip( @@ -1224,6 +1272,7 @@ effectTest( effectTest('publisher.post does not issue POST /messages when pre-flight rejects the channel', () => Effect.gen(function* () { const stub = yield* makeStubHttpClient + yield* seedMessageLimit(stub, 10_000) yield* stub.respond('GET', '/api/v1/streams', { body: { result: 'success', streams: [] } }) yield* stub.respond('POST', '/api/v1/messages', { body: { result: 'success', id: 999 } }) const adapter = yield* buildAdapter(stub) @@ -1249,6 +1298,7 @@ effectTest( () => Effect.gen(function* () { const stub = yield* makeStubHttpClient + yield* seedMessageLimit(stub, 10_000) yield* stub.respond('GET', '/api/v1/streams', { body: { result: 'success', streams: [{ stream_id: 1234, name: 'general' }] }, }) @@ -1274,6 +1324,7 @@ effectTest( effectTest('publisher.post fails with a tagged UnknownChannel on an unknown channel', () => Effect.gen(function* () { const stub = yield* makeStubHttpClient + yield* seedMessageLimit(stub, 10_000) yield* stub.respond('GET', '/api/v1/streams', { body: { result: 'success', streams: [] } }) const adapter = yield* buildAdapter(stub) const error = yield* Effect.flip( @@ -1505,6 +1556,7 @@ effectTest('publisher.post skips the directory fetch when the body has no mentio effectTest('publisher.edit fails with a tagged UnresolvedMention on a dead mention form', () => Effect.gen(function* () { const stub = yield* makeStubHttpClient + yield* seedMessageLimit(stub, 10_000) yield* stub.respond('PATCH', '/api/v1/messages/42', { body: { result: 'success' } }) yield* seedRender(stub, '

now pinging @Alice Example

') const adapter = yield* buildAdapter(stub) @@ -1534,6 +1586,7 @@ effectTest('publisher.edit fails with a tagged UnresolvedMention on a dead menti effectTest('publisher.edit PATCHes /messages/{id} with the new content', () => Effect.gen(function* () { const stub = yield* makeStubHttpClient + yield* seedMessageLimit(stub, 10_000) yield* stub.respond('PATCH', '/api/v1/messages/42', { body: { result: 'success' } }) const adapter = yield* buildAdapter(stub) const target: MessageRef = { @@ -1586,6 +1639,7 @@ effectTest( () => Effect.gen(function* () { const stub = yield* makeStubHttpClient + yield* seedMessageLimit(stub, 10_000) yield* stub.respond('PATCH', '/api/v1/messages/42', { body: { result: 'error', @@ -1617,6 +1671,7 @@ effectTest( () => Effect.gen(function* () { const stub = yield* makeStubHttpClient + yield* seedMessageLimit(stub, 10_000) yield* stub.respond('PATCH', '/api/v1/messages/42', { body: { result: 'error', @@ -1648,6 +1703,7 @@ effectTest( () => Effect.gen(function* () { const stub = yield* makeStubHttpClient + yield* seedMessageLimit(stub, 10_000) yield* stub.respond('PATCH', '/api/v1/messages/42', { body: { result: 'error', @@ -3512,6 +3568,7 @@ effectTest( effectTest('publisher.post after acquire uses BOUND bot creds, not minter creds', () => Effect.gen(function* () { const stub = yield* makeStubHttpClient + yield* seedMessageLimit(stub, 10_000) yield* stub.respond('GET', '/api/v1/streams', { body: { result: 'success', diff --git a/packages/zulip/adapter.ts b/packages/zulip/adapter.ts index e0ede22..774a284 100644 --- a/packages/zulip/adapter.ts +++ b/packages/zulip/adapter.ts @@ -20,6 +20,7 @@ import type { IdentityPort, InboundEvent, Message, + MessageBody, MessageId, MessageInbox, MessagePublisher, @@ -55,6 +56,7 @@ import { InboxError, isBindError, MessageEditRefused, + MessageRejected, mentionsIdentity, PublisherError, UnboundEphemeralSession, @@ -117,6 +119,7 @@ import { mentionTokens, unresolvedMentions, } from './mentions.ts' +import { messageLimitStateSchema, overlongBodyRejection } from './message-length.ts' import { buildMessageRef, permalinkBase, withChannelPermalink } from './permalink.ts' import { type RenderedContentLookup, renderedContentForBatch } from './rendered-content.ts' import { mentionedUserIds, mentionsOfMessage } from './rendered-mentions.ts' @@ -839,6 +842,49 @@ export const zulipAdapter = ( // mention-free body — the common case — never pays for a directory fetch // onto the rate-limited realm. Only user tokens can be dead, so a body // whose only mentions are wildcards or groups skips the fetch too. + // The realm's body cap, read once on first use and kept: it is a server + // setting that changes with a restart, and a failed read is not kept so + // the next write tries again. Read through the minter for the same reason + // as `editingAvailable`: the cap is realm-scoped, not viewer-scoped. + const messageLimitRef = yield* Ref.make>(Option.none()) + const messageLimit = Ref.get(messageLimitRef).pipe( + Effect.flatMap( + Option.match({ + onSome: Effect.succeed, + onNone: () => + minterHttp + .post('/register', messageLimitStateSchema, { + event_types: JSON.stringify([]), + fetch_event_types: JSON.stringify(['realm']), + }) + .pipe( + Effect.tap((res) => + minterHttp + .delete('/events', successSchema, { queue_id: res.queue_id }) + .pipe(Effect.ignore), + ), + Effect.map((res) => res.max_message_length), + Effect.tap((limit) => Ref.set(messageLimitRef, Option.some(limit))), + ), + }), + ), + ) + + // Zulip clips a body past the cap and reports success, so an over-long + // body is refused here, before anything is sent. + const refuseOverlongBody = ( + operation: 'post' | 'edit', + body: MessageBody, + ): Effect.Effect => + messageLimit.pipe( + Effect.flatMap((limit) => + Option.match(overlongBodyRejection(operation, body, limit), { + onNone: () => Effect.void, + onSome: Effect.fail, + }), + ), + ) + const validateOutboundMentions = ( operation: 'post' | 'edit', body: string, @@ -1711,7 +1757,8 @@ export const zulipAdapter = ( // failure as a PublisherError carrying the cause. return boundHttp().pipe( Effect.flatMap((http) => - validateOutboundMentions('post', body).pipe( + refuseOverlongBody('post', body).pipe( + Effect.zipRight(validateOutboundMentions('post', body)), Effect.zipRight(resolvePublishChannel(channel)), Effect.flatMap((effective) => // Zulip rejects stream messages without a topic on realms with @@ -1763,7 +1810,9 @@ export const zulipAdapter = ( // flows out untouched rather than being flattened into a // PublisherError the caller cannot act on. Effect.mapError((cause) => - cause instanceof UnknownChannel || cause instanceof UnresolvedMention + cause instanceof UnknownChannel || + cause instanceof UnresolvedMention || + cause instanceof MessageRejected ? cause : new PublisherError({ operation: 'post', cause }), ), @@ -1774,7 +1823,8 @@ export const zulipAdapter = ( edit: (message, body) => boundHttp().pipe( Effect.flatMap((http) => - validateOutboundMentions('edit', body).pipe( + refuseOverlongBody('edit', body).pipe( + Effect.zipRight(validateOutboundMentions('edit', body)), Effect.zipRight( http.patch(`/messages/${encodeURIComponent(message.id)}`, successSchema, { content: body, @@ -1782,7 +1832,9 @@ export const zulipAdapter = ( ), Effect.asVoid, Effect.mapError((cause) => - cause instanceof UnresolvedMention ? cause : classifyEditFailure(cause), + cause instanceof UnresolvedMention || cause instanceof MessageRejected + ? cause + : classifyEditFailure(cause), ), ), ), diff --git a/packages/zulip/contract.live.test.ts b/packages/zulip/contract.live.test.ts index f83a828..9a02ff9 100644 --- a/packages/zulip/contract.live.test.ts +++ b/packages/zulip/contract.live.test.ts @@ -261,6 +261,7 @@ if (env === undefined) { ), // Stream.MAX_DESCRIPTION_LENGTH — the realm's own cap. channelDescriptionLimit: 1024, + messageLengthLimit: 10_000, dispose: () => Effect.sleep(MINTER_PACE).pipe( Effect.zipRight(adapter.identity.release({ persistent: true })), diff --git a/packages/zulip/in-process-contract.ts b/packages/zulip/in-process-contract.ts index eba3429..724e00e 100644 --- a/packages/zulip/in-process-contract.ts +++ b/packages/zulip/in-process-contract.ts @@ -10,7 +10,11 @@ import type { ContractFactory } from '@commy/testing/contract' import { HttpClient } from '@effect/platform' import { Effect, Option, Redacted } from 'effect' import { type ZulipAdapter, zulipAdapter } from './adapter.ts' -import { CHANNEL_DESCRIPTION_LIMIT, makeInProcessRealm } from './in-process-realm.ts' +import { + CHANNEL_DESCRIPTION_LIMIT, + MESSAGE_LENGTH_LIMIT, + makeInProcessRealm, +} from './in-process-realm.ts' import { permalinkBase, withChannelPermalink } from './permalink.ts' const SELF_NAME = decodeBotNameSync('contract-self') @@ -61,6 +65,7 @@ export const inProcessContractFactory: ContractFactory = async () => { Effect.orDie, ), channelDescriptionLimit: CHANNEL_DESCRIPTION_LIMIT, + messageLengthLimit: MESSAGE_LENGTH_LIMIT, // The realm and its adapters hold nothing outside the heap, so there is // nothing to tear down. dispose: () => Effect.void, diff --git a/packages/zulip/in-process-realm.ts b/packages/zulip/in-process-realm.ts index 10e3a4d..6bac0dd 100644 --- a/packages/zulip/in-process-realm.ts +++ b/packages/zulip/in-process-realm.ts @@ -44,6 +44,9 @@ import { RESOLVED_TOPIC_PREFIX } from './resolved-topic.ts' /** Zulip's `Stream.MAX_DESCRIPTION_LENGTH`. */ export const CHANNEL_DESCRIPTION_LIMIT = 1024 +/** Zulip's default `max_message_length`, which `/register` reports as realm state. */ +export const MESSAGE_LENGTH_LIMIT = 10_000 + export type PresenceStatus = 'active' | 'idle' | 'offline' export interface InProcessRealm { @@ -1177,7 +1180,9 @@ export const makeInProcessRealm: Effect.Effect = Effect.gen(func return ok({ queue_id: queueId, last_event_id: queue.lastIssued, - ...(realmState ? { realm_allow_message_editing: true } : {}), + ...(realmState + ? { realm_allow_message_editing: true, max_message_length: MESSAGE_LENGTH_LIMIT } + : {}), }) }), }, diff --git a/packages/zulip/message-length.ts b/packages/zulip/message-length.ts new file mode 100644 index 0000000..616f93d --- /dev/null +++ b/packages/zulip/message-length.ts @@ -0,0 +1,40 @@ +/** + * Zulip's cap on a message body, kept strictly inside the Zulip adapter. + * + * Zulip does not refuse a body past `max_message_length`. Its body normaliser + * (`normalize_body`, `zerver/lib/message.py`) clips the text and appends a + * truncation marker, then the send answers success. A sender cannot tell its + * message was cut, so the adapter refuses an over-long body before sending. + * The cap is a server setting, published only in the `/register` initial + * state, and counted in Unicode code points. + */ + +import { type MessageBody, MessageRejected } from '@commy/core/ports' +import { Option, Schema } from 'effect' + +/** The slice of `/register` state that carries the cap. */ +export const messageLimitStateSchema = Schema.Struct({ + queue_id: Schema.String, + max_message_length: Schema.Int, +}) + +/** + * The refusal Zulip's own clipping implies for this body, or `None` when Zulip + * will store it whole. + */ +export const overlongBodyRejection = ( + operation: 'post' | 'edit', + body: MessageBody, + limit: number, +): Option.Option => { + const length = [...body].length + return length > limit + ? Option.some( + new MessageRejected({ + operation, + substrate: 'zulip', + detail: `message is too long (limit: ${limit} characters, got ${length})`, + }), + ) + : Option.none() +}