@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.
- package/LICENSE +21 -0
- package/README.md +45 -0
- package/dist/Telegram.d.mts +44 -0
- package/dist/Telegram.d.mts.map +1 -0
- package/dist/Telegram.mjs +208 -0
- package/dist/Telegram.mjs.map +1 -0
- package/dist/api-CnvvfWKq.d.mts +42 -0
- package/dist/api-CnvvfWKq.d.mts.map +1 -0
- package/dist/events-BkljYfG6.d.mts +221 -0
- package/dist/events-BkljYfG6.d.mts.map +1 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.mjs +2 -0
- package/dist/internal/api.d.mts +2 -0
- package/dist/internal/api.mjs +66 -0
- package/dist/internal/api.mjs.map +1 -0
- package/dist/internal/events.d.mts +2 -0
- package/dist/internal/events.mjs +137 -0
- package/dist/internal/events.mjs.map +1 -0
- package/dist/internal/events.test.d.mts +1 -0
- package/dist/internal/events.test.mjs +13818 -0
- package/dist/internal/events.test.mjs.map +1 -0
- package/dist/magic-string.es-CUAnjKYC.mjs +1014 -0
- package/dist/magic-string.es-CUAnjKYC.mjs.map +1 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/package.json +59 -0
- package/src/Telegram.ts +417 -0
- package/src/index.ts +1 -0
- package/src/internal/api.ts +133 -0
- package/src/internal/events.test.ts +112 -0
- package/src/internal/events.ts +232 -0
package/src/Telegram.ts
ADDED
|
@@ -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
|
+
})
|