diff --git a/packages/core/ports.ts b/packages/core/ports.ts index beb7854..82fcc93 100644 --- a/packages/core/ports.ts +++ b/packages/core/ports.ts @@ -560,8 +560,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** @@ -584,7 +585,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 @@ -601,7 +605,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` @@ -937,6 +944,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 4698cac..91f9683 100644 --- a/packages/mcp/tools.ts +++ b/packages/mcp/tools.ts @@ -758,7 +758,7 @@ const buildToolDefs = (deps: RegisterToolsDeps, cache: InternalCache): ReadonlyA { name: 'post', description: - 'Post a message to a channel by name. Optional thread (topic) and reply_to (message id). To ping someone, write the @**Name** markup inline in body where you want it rendered. 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) and reply_to (message id). To ping someone, write the @**Name** markup inline in body where you want it rendered. 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 911a5db..55f1428 100644 --- a/packages/memory/adapter.ts +++ b/packages/memory/adapter.ts @@ -73,6 +73,7 @@ import { Mention, MessageEditRefused, MessagePermalinkSchema, + MessageRejected, mentionsIdentity, notifiesIdentity, PublisherError, @@ -185,6 +186,13 @@ 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 /** * Longest thread name this substrate keeps, in characters. A longer name is * stored cut to this length, as a real substrate cuts an over-long topic, and @@ -201,6 +209,7 @@ export type MemorySeatConfig = Pick< > const DEFAULT_CHANNEL_DESCRIPTION_LIMIT = 512 +const DEFAULT_MESSAGE_LENGTH_LIMIT = 4096 const DEFAULT_THREAD_NAME_LIMIT = 48 export type MemoryAdapter = AgentComms & { @@ -309,6 +318,7 @@ const makeRealm = (config: MemoryAdapterConfig) => const descriptionsByChannel = new Map() const descriptionLimit = config.channelDescriptionLimit ?? DEFAULT_CHANNEL_DESCRIPTION_LIMIT const threadNameLimit = config.threadNameLimit ?? DEFAULT_THREAD_NAME_LIMIT + const messageLimit = config.messageLengthLimit ?? DEFAULT_MESSAGE_LENGTH_LIMIT const nextChannelId = yield* Ref.make(1) const nextIdentityId = yield* Ref.make(1) const nextMessageId = yield* Ref.make(1) @@ -336,6 +346,7 @@ const makeRealm = (config: MemoryAdapterConfig) => descriptionsByChannel, descriptionLimit, threadNameLimit, + messageLimit, nextChannelId, nextIdentityId, nextMessageId, @@ -367,6 +378,7 @@ const memorySeat = (realm: MemoryRealm, config: MemorySeatConfig): Effect.Effect descriptionsByChannel, descriptionLimit, threadNameLimit, + messageLimit, nextChannelId, nextIdentityId, nextMessageId, @@ -375,6 +387,19 @@ const memorySeat = (realm: MemoryRealm, config: MemorySeatConfig): Effect.Effect notificationBot, seats, } = realm + 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 => @@ -861,6 +886,7 @@ const memorySeat = (realm: MemoryRealm, config: MemorySeatConfig): Effect.Effect Effect.gen(function* () { const self = yield* requireBound() const channelRef = yield* resolveChannel(channel) + yield* refuseOverlongBody('post', body) yield* refuseDeadMentions('post', body) const thread = opts?.thread === undefined @@ -875,6 +901,7 @@ const memorySeat = (realm: MemoryRealm, config: MemorySeatConfig): Effect.Effect edit: (message, body) => Effect.gen(function* () { const self = yield* requireBound() + yield* refuseOverlongBody('edit', body) const stored = messagesById.get(message.id) if (stored === undefined) { return yield* new PublisherError({ diff --git a/packages/memory/contract.test.ts b/packages/memory/contract.test.ts index 4470e3f..b89b72b 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 const MEMORY_THREAD_NAME_LIMIT = 48 runAgentCommsContract('memory adapter', async () => { @@ -26,6 +27,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, threadNameLimit: MEMORY_THREAD_NAME_LIMIT, dispose: () => Effect.void, } @@ -70,6 +72,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, threadNameLimit: MEMORY_THREAD_NAME_LIMIT, dispose: () => Effect.void, } diff --git a/packages/testing/contract.ts b/packages/testing/contract.ts index db1d014..af21a5e 100644 --- a/packages/testing/contract.ts +++ b/packages/testing/contract.ts @@ -142,6 +142,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 /** * Longest thread name this substrate keeps, in characters. A substrate that * cuts a longer name short must return the name it kept from `post`; the diff --git a/packages/testing/contract/helpers.ts b/packages/testing/contract/helpers.ts index 57f6a92..92b480c 100644 --- a/packages/testing/contract/helpers.ts +++ b/packages/testing/contract/helpers.ts @@ -4,6 +4,7 @@ import type { ChannelName, InboundEvent, MessageBody, + MessageRejected, PublisherError, UnknownChannel, UnresolvedMention, @@ -23,7 +24,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 04865a4..8f06991 100644 --- a/packages/testing/contract/posting.ts +++ b/packages/testing/contract/posting.ts @@ -11,6 +11,7 @@ import { decodeThreadNameSync, Mention, type MessageRef, + MessageRejected, mentionedIdentities, UnboundEphemeralSession, UnknownChannel, @@ -55,6 +56,30 @@ const makeCountingBinder = ( }) 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 e8eae90..2501154 100644 --- a/packages/testing/contract/publisher.ts +++ b/packages/testing/contract/publisher.ts @@ -13,6 +13,7 @@ import { MessageEditRefused, MessagePermalinkSchema, type MessageRef, + MessageRejected, mentionedIdentities, PublisherError, UnknownChannel, @@ -34,6 +35,19 @@ const drainUntilSilent = (queue: Queue.Queue): Effect.Effect ) 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 190a7f6..f83f772 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, @@ -753,6 +754,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' } }) }) // What the realm reports for a sent message's topic. The send response omits @@ -785,6 +803,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 accepts a plain mention when a deactivated namesake is listed after the active member', () => @@ -1057,6 +1104,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( @@ -1074,6 +1122,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) @@ -1099,6 +1148,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' }] }, }) @@ -1299,6 +1349,7 @@ effectTest('publisher.post skips the directory fetch when the body has no mentio 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 = { @@ -1351,6 +1402,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', @@ -1382,6 +1434,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', @@ -2753,6 +2806,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 cc1a392..5216ee0 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, notifiesIdentity, PublisherError, @@ -119,6 +121,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' @@ -879,6 +882,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 deadMentionsAfterFreshRead = (body: string) => buildDirectoryLookup().pipe( Effect.flatMap((directory) => { @@ -1784,7 +1830,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 @@ -1846,7 +1893,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 }), ), @@ -1857,7 +1906,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, @@ -1865,7 +1915,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 76aac91..c3f3a45 100644 --- a/packages/zulip/contract.live.test.ts +++ b/packages/zulip/contract.live.test.ts @@ -272,6 +272,7 @@ if (env === undefined) { ), // Stream.MAX_DESCRIPTION_LENGTH — the realm's own cap. channelDescriptionLimit: 1024, + messageLengthLimit: 10_000, // MAX_TOPIC_NAME_LENGTH — the realm's own cap. threadNameLimit: 60, dispose: () => diff --git a/packages/zulip/in-process-contract.ts b/packages/zulip/in-process-contract.ts index 2a73337..35dc50f 100644 --- a/packages/zulip/in-process-contract.ts +++ b/packages/zulip/in-process-contract.ts @@ -17,7 +17,12 @@ import type { AttachmentContractFactory } from '@commy/testing/contract' import { HttpClient } from '@effect/platform' import { Array as Arr, Effect, Option, Redacted } from 'effect' import { type ZulipAdapter, zulipAdapter } from './adapter.ts' -import { CHANNEL_DESCRIPTION_LIMIT, makeInProcessRealm, TOPIC_LIMIT } from './in-process-realm.ts' +import { + CHANNEL_DESCRIPTION_LIMIT, + MESSAGE_LENGTH_LIMIT, + makeInProcessRealm, + TOPIC_LIMIT, +} from './in-process-realm.ts' import { permalinkBase, withChannelPermalink } from './permalink.ts' const SELF_NAME = decodeBotNameSync('contract-self') @@ -96,6 +101,7 @@ export const inProcessContractFactory: AttachmentContractFactory = async () => { .currentIdentity() .pipe(Effect.flatMap((me) => realm.revokeApiKey(Number(me.id)))), channelDescriptionLimit: CHANNEL_DESCRIPTION_LIMIT, + messageLengthLimit: MESSAGE_LENGTH_LIMIT, threadNameLimit: TOPIC_LIMIT, // The realm and its adapters hold nothing outside the heap, so there is // nothing to tear down. diff --git a/packages/zulip/in-process-realm.ts b/packages/zulip/in-process-realm.ts index 7b5ad99..d77303a 100644 --- a/packages/zulip/in-process-realm.ts +++ b/packages/zulip/in-process-realm.ts @@ -44,6 +44,8 @@ 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 /** Zulip's `MAX_TOPIC_NAME_LENGTH`. A longer topic is stored cut short with an ellipsis. */ export const TOPIC_LIMIT = 60 const ELLIPSIS = '...' @@ -1218,7 +1220,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() +}