@effect-uai/telegram 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,417 @@
1
+ import {
2
+ Array as Arr,
3
+ type Cause,
4
+ Context,
5
+ Duration,
6
+ Effect,
7
+ Encoding,
8
+ Layer,
9
+ Match,
10
+ Option,
11
+ Queue,
12
+ Ref,
13
+ Schedule,
14
+ Schema,
15
+ type Scope,
16
+ Stream,
17
+ } from "effect"
18
+ import { HttpClient } from "effect/unstable/http"
19
+ import type { MediaSource } from "@effect-uai/core/Media"
20
+ import {
21
+ CurrentConversation,
22
+ type ConversationRef,
23
+ type InboundEvent,
24
+ MessageId,
25
+ Messenger,
26
+ type MessengerLimits,
27
+ type MessengerService,
28
+ type Outbound,
29
+ type OutboundBody,
30
+ } from "@effect-uai/core/Messenger"
31
+ import {
32
+ type StreamViaEditsOptions,
33
+ splitForLimit,
34
+ streamViaEdits,
35
+ } from "@effect-uai/core/MessengerAdapter"
36
+ import * as MessengerError from "@effect-uai/core/MessengerError"
37
+ import * as Api from "./internal/api.js"
38
+ import * as Events from "./internal/events.js"
39
+
40
+ export type { BotIdentity } from "./internal/events.js"
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Public types
44
+ // ---------------------------------------------------------------------------
45
+
46
+ /** Telegram's own markup modes. `Text` bodies are sent verbatim under one. */
47
+ export type ParseMode = "HTML" | "MarkdownV2" | "Markdown"
48
+
49
+ export type Config = Api.Config & {
50
+ /**
51
+ * Markup the model is prompted to write. Default `"HTML"`; `"plain"` sends
52
+ * text with no `parse_mode` at all.
53
+ */
54
+ readonly parseMode?: ParseMode | "plain"
55
+ /** Long-poll wait per `getUpdates` call. Default 30 seconds. */
56
+ readonly pollTimeout?: Duration.Input
57
+ /** Tuning for `stream`, which is post-then-edit on Telegram. */
58
+ readonly stream?: StreamViaEditsOptions
59
+ }
60
+
61
+ export type TelegramService = MessengerService & {
62
+ /** From `getMe` at layer build: the identity behind `addressed`. */
63
+ readonly bot: Events.BotIdentity
64
+ }
65
+
66
+ /**
67
+ * Provider-typed tag. Yield this for the bot identity; yield the generic
68
+ * `Messenger` tag for provider-portable code. Both are registered by {@link layer}.
69
+ */
70
+ export class Telegram extends Context.Service<Telegram, TelegramService>()(
71
+ "@betalyra/effect-uai/providers/telegram/Telegram",
72
+ ) {}
73
+
74
+ export const limits: MessengerLimits = { maxText: 4096, maxCaption: 1024 }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Wire results
78
+ // ---------------------------------------------------------------------------
79
+
80
+ const Me = Schema.Struct({ id: Schema.Number, username: Schema.String })
81
+ const Sent = Schema.Struct({ message_id: Schema.Number })
82
+ const Updates = Schema.Array(Events.Update)
83
+ const RawCall = Schema.Struct({
84
+ method: Schema.String,
85
+ params: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
86
+ })
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Errors
90
+ // ---------------------------------------------------------------------------
91
+
92
+ const isApiFailure = (e: Api.ApiError): e is Api.ApiFailure => e._tag === "TelegramApiFailure"
93
+
94
+ const describes = (needle: string) => (e: Api.ApiError) =>
95
+ isApiFailure(e) && e.description.includes(needle)
96
+
97
+ const reasonOf = (e: Api.ApiError): string => (isApiFailure(e) ? e.description : "rate limited")
98
+
99
+ /** An API rejection becomes the verb's `MessengerRequestFailed`; typed errors pass through. */
100
+ const requestFailed =
101
+ (operation: MessengerError.MessengerOperation) =>
102
+ (e: Api.ApiError | MessengerError.MessengerError): MessengerError.MessengerError =>
103
+ Match.value(e).pipe(
104
+ Match.tag(
105
+ "TelegramApiFailure",
106
+ (failure) =>
107
+ new MessengerError.MessengerRequestFailed({
108
+ provider: Api.provider,
109
+ operation,
110
+ reason: failure.description,
111
+ raw: failure.raw,
112
+ }),
113
+ ),
114
+ Match.orElse((typed) => typed),
115
+ )
116
+
117
+ const unsupported = (capability: string, reason: string) =>
118
+ new MessengerError.MessengerUnsupported({ provider: Api.provider, capability, reason })
119
+
120
+ // 401 is a dead token and 409 a second poller on it; nothing else ends the stream.
121
+ const isFatalPoll = (e: Api.ApiError) => isApiFailure(e) && (e.code === 401 || e.code === 409)
122
+
123
+ // Transient poll failures wait five seconds, or exactly what a 429 asked for.
124
+ const pollBackoff: Schedule.Schedule<number, Api.ApiError> = Schedule.forever.pipe(
125
+ Schedule.setInputType<Api.ApiError>(),
126
+ Schedule.modifyDelay(({ input }) =>
127
+ Effect.succeed(input._tag === "MessengerRateLimited" ? input.retryAfter : Duration.seconds(5)),
128
+ ),
129
+ )
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Outbound params
133
+ // ---------------------------------------------------------------------------
134
+
135
+ const target = (at: ConversationRef): Api.Params => ({
136
+ chat_id: at.channel,
137
+ ...(at.thread !== undefined && { message_thread_id: Number(at.thread) }),
138
+ })
139
+
140
+ const replyParams = (msg: Outbound): Api.Params =>
141
+ msg.replyTo === undefined ? {} : { reply_parameters: { message_id: Number(msg.replyTo) } }
142
+
143
+ const parseParams = (cfg: Config): Api.Params =>
144
+ cfg.parseMode === "plain" ? {} : { parse_mode: cfg.parseMode ?? "HTML" }
145
+
146
+ type MediaRoute = { readonly method: string; readonly field: string }
147
+
148
+ /** The endpoint a `MediaSource` goes through, decided by its MIME type. */
149
+ const mediaRoute = (media: MediaSource): MediaRoute =>
150
+ Match.value(media.mimeType ?? "").pipe(
151
+ Match.when(
152
+ (m) => m.startsWith("image/"),
153
+ (): MediaRoute => ({ method: "sendPhoto", field: "photo" }),
154
+ ),
155
+ Match.when(
156
+ (m) => m.startsWith("audio/"),
157
+ (): MediaRoute => ({ method: "sendAudio", field: "audio" }),
158
+ ),
159
+ Match.when(
160
+ (m) => m.startsWith("video/"),
161
+ (): MediaRoute => ({ method: "sendVideo", field: "video" }),
162
+ ),
163
+ Match.orElse((): MediaRoute => ({ method: "sendDocument", field: "document" })),
164
+ )
165
+
166
+ const defaultFilename = (media: MediaSource): string =>
167
+ `file.${media.mimeType?.split("/")[1] ?? "bin"}`
168
+
169
+ const invalidMedia = (raw: unknown) =>
170
+ new MessengerError.MessengerRequestFailed({
171
+ provider: Api.provider,
172
+ operation: "post",
173
+ reason: "invalid base64 media",
174
+ raw,
175
+ })
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // Service
179
+ // ---------------------------------------------------------------------------
180
+
181
+ export const make = (
182
+ cfg: Config,
183
+ ): Effect.Effect<
184
+ TelegramService,
185
+ MessengerError.MessengerConnectFailed,
186
+ HttpClient.HttpClient | Scope.Scope
187
+ > =>
188
+ Effect.gen(function* () {
189
+ // Captured once so the verbs need no `HttpClient` of their own.
190
+ const client = yield* HttpClient.HttpClient
191
+ const withClient = Effect.provideService(HttpClient.HttpClient, client)
192
+ const call = (method: string, params?: Api.Params) => withClient(Api.call(cfg)(method, params))
193
+ const upload = (method: string, params: Api.Params, file: Api.Upload) =>
194
+ withClient(Api.upload(cfg)(method, params, file))
195
+
196
+ const bot = yield* call("getMe").pipe(
197
+ Api.decoded("getMe", Me),
198
+ Effect.mapError(
199
+ (e) =>
200
+ new MessengerError.MessengerConnectFailed({
201
+ provider: Api.provider,
202
+ reason: reasonOf(e),
203
+ raw: e.raw,
204
+ }),
205
+ ),
206
+ )
207
+
208
+ // -- inbound -----------------------------------------------------------
209
+
210
+ const inbox = yield* Queue.unbounded<InboundEvent, MessengerError.MessengerError | Cause.Done>()
211
+ const offset = yield* Ref.make(0)
212
+ const toEvents = Events.toEvents(bot)
213
+
214
+ // Answered before the event is offered, so the client's spinner never
215
+ // waits on the recipe.
216
+ const acknowledge = (update: Events.Update) =>
217
+ Match.value(update).pipe(
218
+ Match.when({ callback_query: Match.defined }, ({ callback_query }) =>
219
+ call("answerCallbackQuery", { callback_query_id: callback_query.id }).pipe(Effect.ignore),
220
+ ),
221
+ Match.orElse(() => Effect.void),
222
+ )
223
+
224
+ const poll = Effect.gen(function* () {
225
+ const updates = yield* call("getUpdates", {
226
+ offset: yield* Ref.get(offset),
227
+ timeout: Duration.toSeconds(cfg.pollTimeout ?? "30 seconds"),
228
+ allowed_updates: Events.allowedUpdates,
229
+ }).pipe(Api.decoded("getUpdates", Updates))
230
+ yield* Effect.forEach(updates, (update) =>
231
+ Effect.gen(function* () {
232
+ yield* Ref.set(offset, update.update_id + 1)
233
+ yield* acknowledge(update)
234
+ yield* Queue.offerAll(inbox, toEvents(update))
235
+ }),
236
+ )
237
+ })
238
+
239
+ // Transient failures (network, 5xx, 429) back off and poll again; fatal
240
+ // ones end the stream with `MessengerTransportClosed`.
241
+ yield* poll.pipe(
242
+ Effect.retry({ while: (e) => !isFatalPoll(e), schedule: pollBackoff }),
243
+ Effect.forever,
244
+ Effect.catch((e) =>
245
+ Queue.fail(
246
+ inbox,
247
+ new MessengerError.MessengerTransportClosed({
248
+ provider: Api.provider,
249
+ reason: reasonOf(e),
250
+ raw: e.raw,
251
+ }),
252
+ ),
253
+ ),
254
+ Effect.ensuring(Queue.end(inbox)),
255
+ Effect.forkScoped,
256
+ )
257
+
258
+ // -- outbound ----------------------------------------------------------
259
+
260
+ // Telegram rejects markup it cannot parse; the same text then goes out plain.
261
+ const sendText = (method: string, params: Api.Params) =>
262
+ call(method, { ...params, ...parseParams(cfg) }).pipe(
263
+ Effect.catchIf(describes("can't parse entities"), () => call(method, params)),
264
+ )
265
+
266
+ const sendMedia = (
267
+ at: ConversationRef,
268
+ msg: Outbound,
269
+ body: Extract<OutboundBody, { _tag: "Media" }>,
270
+ ) => {
271
+ const { method, field } = mediaRoute(body.media)
272
+ const params = {
273
+ ...target(at),
274
+ ...replyParams(msg),
275
+ ...(body.caption !== undefined && { caption: body.caption, ...parseParams(cfg) }),
276
+ }
277
+ const send = (bytes: Uint8Array, mimeType: string) =>
278
+ upload(method, params, {
279
+ field,
280
+ bytes,
281
+ filename: body.filename ?? defaultFilename(body.media),
282
+ mimeType,
283
+ })
284
+ return Match.value(body.media).pipe(
285
+ Match.tag("url", ({ url }) => call(method, { ...params, [field]: url })),
286
+ Match.tag("bytes", ({ bytes, mimeType }) => send(bytes, mimeType)),
287
+ Match.tag("base64", ({ base64, mimeType }) =>
288
+ Effect.fromResult(Encoding.decodeBase64(base64)).pipe(
289
+ Effect.mapError(invalidMedia),
290
+ Effect.flatMap((bytes) => send(bytes, mimeType)),
291
+ ),
292
+ ),
293
+ Match.exhaustive,
294
+ )
295
+ }
296
+
297
+ const sendRaw = (payload: unknown) =>
298
+ Effect.gen(function* () {
299
+ const { method, params } = yield* Schema.decodeUnknownEffect(RawCall)(payload).pipe(
300
+ Effect.mapError(
301
+ () =>
302
+ new MessengerError.MessengerRequestFailed({
303
+ provider: Api.provider,
304
+ operation: "post",
305
+ reason: "raw payload must be { method, params? }",
306
+ raw: payload,
307
+ }),
308
+ ),
309
+ )
310
+ return yield* call(method, params)
311
+ })
312
+
313
+ // The reply lands on the first chunk; the id is the last one's.
314
+ const sendChunks = (at: ConversationRef, msg: Outbound, text: string) =>
315
+ Effect.gen(function* () {
316
+ const [first, ...rest] = splitForLimit(text, limits.maxText)
317
+ const send = (chunk: string, extra: Api.Params) =>
318
+ sendText("sendMessage", { ...target(at), ...extra, text: chunk }).pipe(
319
+ Api.decoded("sendMessage", Sent),
320
+ )
321
+ const head = yield* send(first, replyParams(msg))
322
+ const tail = yield* Effect.forEach(rest, (chunk) => send(chunk, {}))
323
+ return Option.getOrElse(Arr.last(tail), () => head)
324
+ })
325
+
326
+ const post: MessengerService["post"] = (msg) =>
327
+ Effect.gen(function* () {
328
+ const at = yield* CurrentConversation
329
+ const sent = yield* Match.value(msg.body).pipe(
330
+ Match.tag("Text", ({ text }) => sendChunks(at, msg, text)),
331
+ Match.tag("Media", (body) =>
332
+ sendMedia(at, msg, body).pipe(Api.decoded("sendMedia", Sent)),
333
+ ),
334
+ Match.tag("Raw", ({ payload }) => sendRaw(payload).pipe(Api.decoded("raw", Sent))),
335
+ Match.exhaustive,
336
+ )
337
+ return MessageId(String(sent.message_id))
338
+ }).pipe(Effect.mapError(requestFailed("post")))
339
+
340
+ const edit: MessengerService["edit"] = (ref, next) =>
341
+ Match.value(next.body).pipe(
342
+ Match.tag("Text", ({ text }) =>
343
+ sendText("editMessageText", {
344
+ chat_id: ref.conversation.channel,
345
+ message_id: Number(ref.id),
346
+ text,
347
+ }).pipe(Effect.asVoid, Effect.mapError(requestFailed("edit"))),
348
+ ),
349
+ Match.tag("Raw", ({ payload }) =>
350
+ sendRaw(payload).pipe(Effect.asVoid, Effect.mapError(requestFailed("edit"))),
351
+ ),
352
+ Match.tag("Media", () =>
353
+ Effect.fail(unsupported("media edits", "send a new message instead")),
354
+ ),
355
+ Match.exhaustive,
356
+ )
357
+
358
+ // Telegram allows a fixed emoji set; anything else is REACTION_INVALID.
359
+ const react: MessengerService["react"] = (ref, emoji) =>
360
+ call("setMessageReaction", {
361
+ chat_id: ref.conversation.channel,
362
+ message_id: Number(ref.id),
363
+ reaction: [{ type: "emoji", emoji }],
364
+ }).pipe(
365
+ Effect.asVoid,
366
+ Effect.catchIf(describes("REACTION_INVALID"), () =>
367
+ Effect.fail(unsupported("reaction", `${emoji} is not in Telegram's reaction set`)),
368
+ ),
369
+ Effect.mapError(requestFailed("react")),
370
+ )
371
+
372
+ // The indicator lasts about five seconds, so it is re-sent every four
373
+ // until the scope closes. The first send is awaited so a bad chat fails here.
374
+ const typing: MessengerService["typing"] = Effect.gen(function* () {
375
+ const at = yield* CurrentConversation
376
+ const once = call("sendChatAction", { ...target(at), action: "typing" })
377
+ yield* once.pipe(Effect.mapError(requestFailed("typing")))
378
+ yield* once.pipe(
379
+ Effect.ignore,
380
+ Effect.schedule(Schedule.spaced("4 seconds")),
381
+ Effect.forkScoped,
382
+ )
383
+ })
384
+
385
+ return {
386
+ bot,
387
+ events: Stream.fromQueue(inbox),
388
+ post,
389
+ edit,
390
+ react,
391
+ typing,
392
+ stream: streamViaEdits({ post, edit, limits }, cfg.stream),
393
+ limits,
394
+ }
395
+ })
396
+
397
+ // ---------------------------------------------------------------------------
398
+ // Layer
399
+ // ---------------------------------------------------------------------------
400
+
401
+ /**
402
+ * One poller, registered under both the `Telegram` and `Messenger` tags. The
403
+ * long-poll loop starts when the layer is built and stops when its scope
404
+ * closes; `events` ends with it. Telegram allows one poller per token.
405
+ */
406
+ export const layer = (
407
+ cfg: Config,
408
+ ): Layer.Layer<
409
+ Telegram | Messenger,
410
+ MessengerError.MessengerConnectFailed,
411
+ HttpClient.HttpClient
412
+ > =>
413
+ Layer.effectContext(
414
+ Effect.map(make(cfg), (service) =>
415
+ Context.make(Telegram, service).pipe(Context.add(Messenger, service)),
416
+ ),
417
+ )
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * as Telegram from "./Telegram.js"
@@ -0,0 +1,133 @@
1
+ import { Array as Arr, Data, Duration, Effect, Redacted, Result, Schema, pipe } from "effect"
2
+ import { HttpClient, HttpClientRequest } from "effect/unstable/http"
3
+ import * as MessengerError from "@effect-uai/core/MessengerError"
4
+ import * as Multipart from "@effect-uai/core/Multipart"
5
+
6
+ export const provider = "telegram"
7
+
8
+ export type Config = {
9
+ readonly token: Redacted.Redacted
10
+ /** Defaults to `https://api.telegram.org`. */
11
+ readonly baseUrl?: string
12
+ }
13
+
14
+ /**
15
+ * A Bot API call that came back `ok: false` or never came back at all.
16
+ * `code` mirrors the HTTP status Telegram puts in `error_code`; each verb
17
+ * maps this onto its own `MessengerError`.
18
+ */
19
+ export class ApiFailure extends Data.TaggedError("TelegramApiFailure")<{
20
+ readonly method: string
21
+ readonly code?: number
22
+ readonly description: string
23
+ readonly raw: unknown
24
+ }> {}
25
+
26
+ export type ApiError = ApiFailure | MessengerError.MessengerRateLimited
27
+
28
+ const Envelope = Schema.Struct({
29
+ ok: Schema.Boolean,
30
+ result: Schema.optional(Schema.Unknown),
31
+ description: Schema.optional(Schema.String),
32
+ error_code: Schema.optional(Schema.Number),
33
+ parameters: Schema.optional(Schema.Struct({ retry_after: Schema.optional(Schema.Number) })),
34
+ })
35
+
36
+ export type Params = Readonly<Record<string, unknown>>
37
+
38
+ /** The file part of a multipart upload. */
39
+ export type Upload = {
40
+ readonly field: string
41
+ readonly bytes: Uint8Array
42
+ readonly filename: string
43
+ readonly mimeType: string
44
+ }
45
+
46
+ const endpoint = (cfg: Config, method: string): string =>
47
+ `${cfg.baseUrl ?? "https://api.telegram.org"}/bot${Redacted.value(cfg.token)}/${method}`
48
+
49
+ const transportFailure = (method: string) => (raw: unknown) =>
50
+ new ApiFailure({ method, description: "transport failure", raw })
51
+
52
+ const rejected = (method: string, envelope: typeof Envelope.Type): ApiError =>
53
+ envelope.parameters?.retry_after !== undefined
54
+ ? new MessengerError.MessengerRateLimited({
55
+ provider,
56
+ retryAfter: Duration.seconds(envelope.parameters.retry_after),
57
+ raw: envelope,
58
+ })
59
+ : new ApiFailure({
60
+ method,
61
+ ...(envelope.error_code !== undefined && { code: envelope.error_code }),
62
+ description: envelope.description ?? "unknown error",
63
+ raw: envelope,
64
+ })
65
+
66
+ const send = (
67
+ method: string,
68
+ request: HttpClientRequest.HttpClientRequest,
69
+ ): Effect.Effect<unknown, ApiError, HttpClient.HttpClient> =>
70
+ Effect.gen(function* () {
71
+ const client = yield* HttpClient.HttpClient
72
+ const response = yield* client.execute(request).pipe(Effect.mapError(transportFailure(method)))
73
+ const json = yield* response.json.pipe(Effect.mapError(transportFailure(method)))
74
+ const envelope = yield* Schema.decodeUnknownEffect(Envelope)(json).pipe(
75
+ Effect.mapError(
76
+ () => new ApiFailure({ method, description: "malformed envelope", raw: json }),
77
+ ),
78
+ )
79
+ return envelope.ok ? envelope.result : yield* rejected(method, envelope)
80
+ })
81
+
82
+ /** One JSON-bodied method call. `result` comes back undecoded. */
83
+ export const call =
84
+ (cfg: Config) =>
85
+ (method: string, params: Params = {}): Effect.Effect<unknown, ApiError, HttpClient.HttpClient> =>
86
+ send(
87
+ method,
88
+ HttpClientRequest.post(endpoint(cfg, method)).pipe(HttpClientRequest.bodyJsonUnsafe(params)),
89
+ )
90
+
91
+ type Entry = readonly [name: string, value: string | File]
92
+
93
+ // A `File` carries its own filename, so one entry shape covers both kinds.
94
+ const entries = (params: Params, file: Upload): ReadonlyArray<Entry> =>
95
+ pipe(
96
+ Object.entries(params),
97
+ Arr.filterMap(([name, value]) =>
98
+ value === undefined ? Result.failVoid : Result.succeed([name, String(value)] as const),
99
+ ),
100
+ Arr.append([
101
+ file.field,
102
+ new File([file.bytes as Uint8Array<ArrayBuffer>], file.filename, { type: file.mimeType }),
103
+ ] as const),
104
+ )
105
+
106
+ const formData = (fields: ReadonlyArray<Entry>): FormData =>
107
+ fields.reduce((form, [name, value]) => (form.append(name, value), form), new FormData())
108
+
109
+ /** One multipart method call: string fields plus a single file part. */
110
+ export const upload =
111
+ (cfg: Config) =>
112
+ (
113
+ method: string,
114
+ params: Params,
115
+ file: Upload,
116
+ ): Effect.Effect<unknown, ApiError, HttpClient.HttpClient> =>
117
+ Effect.gen(function* () {
118
+ const body = yield* Multipart.bodyMultipart(formData(entries(params, file))).pipe(
119
+ Effect.mapError(transportFailure(method)),
120
+ )
121
+ return yield* send(method, HttpClientRequest.post(endpoint(cfg, method)).pipe(body))
122
+ })
123
+
124
+ /** Decode a call's `result`; a shape mismatch is a malformed response, not a defect. */
125
+ export const decoded =
126
+ <A>(method: string, schema: Schema.Decoder<A>) =>
127
+ <E, R>(result: Effect.Effect<unknown, E, R>): Effect.Effect<A, E | ApiFailure, R> =>
128
+ Effect.gen(function* () {
129
+ const raw = yield* result
130
+ return yield* Schema.decodeUnknownEffect(schema)(raw).pipe(
131
+ Effect.mapError(() => new ApiFailure({ method, description: "malformed result", raw })),
132
+ )
133
+ })
@@ -0,0 +1,112 @@
1
+ import { Schema } from "effect"
2
+ import { describe, expect, it } from "vitest"
3
+ import { type BotIdentity, Update, toEvents } from "./events.js"
4
+
5
+ const bot: BotIdentity = { id: 42, username: "HelperBot" }
6
+ const events = toEvents(bot)
7
+
8
+ const alice = { id: 7, is_bot: false, username: "alice" }
9
+ const group = { id: -100123, type: "supergroup" }
10
+
11
+ /** A `message` update through the real decoder, as the poller hands it over. */
12
+ const message = (fields: Record<string, unknown>) =>
13
+ events(
14
+ Schema.decodeUnknownSync(Update)({
15
+ update_id: 1,
16
+ message: { message_id: 10, from: alice, chat: group, ...fields },
17
+ }),
18
+ )[0]
19
+
20
+ const entity = (type: string, text: string, needle: string) => ({
21
+ type,
22
+ offset: text.indexOf(needle),
23
+ length: needle.length,
24
+ })
25
+
26
+ describe("addressed", () => {
27
+ it("is a DM, a mention, or a reply to the bot, and nothing else", () => {
28
+ const mentioned = "@helperbot ping"
29
+ expect(message({ chat: { id: 7, type: "private" }, text: "hi" })).toMatchObject({
30
+ addressed: true,
31
+ })
32
+ expect(
33
+ message({ text: mentioned, entities: [entity("mention", mentioned, "@helperbot")] }),
34
+ ).toMatchObject({ addressed: true })
35
+ expect(
36
+ message({
37
+ text: "and?",
38
+ reply_to_message: { message_id: 9, from: { id: 42, is_bot: true } },
39
+ }),
40
+ ).toMatchObject({ addressed: true, replyTo: "9" })
41
+ expect(
42
+ message({ text: "and?", reply_to_message: { message_id: 9, from: alice } }),
43
+ ).toMatchObject({ addressed: false })
44
+ expect(message({ text: "lunch anyone" })).toMatchObject({ addressed: false })
45
+ })
46
+
47
+ it("strips only the bot's own mention, wherever it sits", () => {
48
+ const text = "@alice ask @HelperBot: what time is it?"
49
+ expect(
50
+ message({
51
+ text,
52
+ entities: [entity("mention", text, "@alice"), entity("mention", text, "@HelperBot")],
53
+ }),
54
+ ).toMatchObject({ text: "@alice ask : what time is it?" })
55
+ })
56
+ })
57
+
58
+ describe("commands", () => {
59
+ it("parses `/name@bot args` at offset 0 and strips the bot suffix", () => {
60
+ const text = "/search@HelperBot effect streams"
61
+ expect(
62
+ message({ text, entities: [entity("bot_command", text, "/search@HelperBot")] }),
63
+ ).toMatchObject({ _tag: "Command", name: "search", args: "effect streams" })
64
+ })
65
+
66
+ it("leaves a mid-text /word and another bot's command as plain messages", () => {
67
+ const mid = "try /help later"
68
+ const other = "/start@OtherBot"
69
+ expect(message({ text: mid, entities: [entity("bot_command", mid, "/help")] })).toMatchObject({
70
+ _tag: "Message",
71
+ text: mid,
72
+ })
73
+ expect(message({ text: other, entities: [entity("bot_command", other, other)] })).toMatchObject(
74
+ { _tag: "Message", addressed: false },
75
+ )
76
+ })
77
+ })
78
+
79
+ describe("threads", () => {
80
+ it("is the forum topic, never a reply chain's thread id", () => {
81
+ expect(message({ text: "a", message_thread_id: 5, is_topic_message: true })).toMatchObject({
82
+ conversation: { channel: "-100123", thread: "5" },
83
+ })
84
+ expect(message({ text: "b", message_thread_id: 5 })).toMatchObject({
85
+ conversation: { channel: "-100123" },
86
+ })
87
+ expect(message({ text: "b", message_thread_id: 5 })).not.toHaveProperty("conversation.thread")
88
+ })
89
+ })
90
+
91
+ describe("other updates", () => {
92
+ it("emits one Reaction per emoji and skips custom ones", () => {
93
+ const update = Schema.decodeUnknownSync(Update)({
94
+ update_id: 2,
95
+ message_reaction: {
96
+ chat: group,
97
+ message_id: 10,
98
+ user: alice,
99
+ new_reaction: [{ type: "emoji", emoji: "👍" }, { type: "custom_emoji" }],
100
+ },
101
+ })
102
+ expect(events(update)).toMatchObject([{ _tag: "Reaction", emoji: "👍", message: "10" }])
103
+ })
104
+
105
+ it("swallows edited messages so they are acked but never re-answered", () => {
106
+ const update = Schema.decodeUnknownSync(Update)({
107
+ update_id: 4,
108
+ edited_message: { message_id: 10, from: alice, chat: group, text: "edited" },
109
+ })
110
+ expect(events(update)).toEqual([])
111
+ })
112
+ })