@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,232 @@
1
+ import { Array as Arr, Match, Option, Schema } from "effect"
2
+ import {
3
+ ChannelId,
4
+ type ConversationRef,
5
+ InboundEvent,
6
+ MessageId,
7
+ ThreadId,
8
+ UserId,
9
+ } from "@effect-uai/core/Messenger"
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Wire shapes: the fields this adapter reads. The rest survives on `raw`.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export const User = Schema.Struct({
16
+ id: Schema.Number,
17
+ is_bot: Schema.Boolean,
18
+ username: Schema.optional(Schema.String),
19
+ })
20
+ export type User = typeof User.Type
21
+
22
+ export const Chat = Schema.Struct({
23
+ id: Schema.Number,
24
+ type: Schema.String,
25
+ })
26
+ export type Chat = typeof Chat.Type
27
+
28
+ export const Entity = Schema.Struct({
29
+ type: Schema.String,
30
+ offset: Schema.Number,
31
+ length: Schema.Number,
32
+ })
33
+ export type Entity = typeof Entity.Type
34
+
35
+ /** Where a message sits: its chat, and its forum topic when it is in one. */
36
+ export const Placement = Schema.Struct({
37
+ chat: Chat,
38
+ message_thread_id: Schema.optional(Schema.Number),
39
+ is_topic_message: Schema.optional(Schema.Boolean),
40
+ })
41
+ export type Placement = typeof Placement.Type
42
+
43
+ export const ReplyTarget = Schema.Struct({
44
+ message_id: Schema.Number,
45
+ from: Schema.optional(User),
46
+ })
47
+ export type ReplyTarget = typeof ReplyTarget.Type
48
+
49
+ export const Message = Schema.Struct({
50
+ ...Placement.fields,
51
+ message_id: Schema.Number,
52
+ from: Schema.optional(User),
53
+ text: Schema.optional(Schema.String),
54
+ caption: Schema.optional(Schema.String),
55
+ entities: Schema.optional(Schema.Array(Entity)),
56
+ reply_to_message: Schema.optional(ReplyTarget),
57
+ })
58
+ export type Message = typeof Message.Type
59
+
60
+ export const Reaction = Schema.Struct({
61
+ type: Schema.String,
62
+ emoji: Schema.optional(Schema.String),
63
+ })
64
+ export type Reaction = typeof Reaction.Type
65
+
66
+ export const MessageReaction = Schema.Struct({
67
+ chat: Chat,
68
+ message_id: Schema.Number,
69
+ user: Schema.optional(User),
70
+ new_reaction: Schema.Array(Reaction),
71
+ })
72
+ export type MessageReaction = typeof MessageReaction.Type
73
+
74
+ export const CallbackQuery = Schema.Struct({
75
+ id: Schema.String,
76
+ from: User,
77
+ data: Schema.optional(Schema.String),
78
+ message: Schema.optional(Schema.Struct({ ...Placement.fields, message_id: Schema.Number })),
79
+ })
80
+ export type CallbackQuery = typeof CallbackQuery.Type
81
+
82
+ export const Update = Schema.Struct({
83
+ update_id: Schema.Number,
84
+ message: Schema.optional(Message),
85
+ edited_message: Schema.optional(Message),
86
+ message_reaction: Schema.optional(MessageReaction),
87
+ callback_query: Schema.optional(CallbackQuery),
88
+ })
89
+ export type Update = typeof Update.Type
90
+
91
+ /** Requested on every `getUpdates`; without it Telegram never delivers reactions. */
92
+ export const allowedUpdates = [
93
+ "message",
94
+ "edited_message",
95
+ "callback_query",
96
+ "message_reaction",
97
+ ] as const
98
+
99
+ export type BotIdentity = {
100
+ readonly id: number
101
+ readonly username: string
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Mapping
106
+ // ---------------------------------------------------------------------------
107
+
108
+ const conversation = (at: Placement): ConversationRef => ({
109
+ channel: ChannelId(String(at.chat.id)),
110
+ // Forum topics are threads; reply chains are not (they carry no thread id).
111
+ ...(at.is_topic_message === true &&
112
+ at.message_thread_id !== undefined && { thread: ThreadId(String(at.message_thread_id)) }),
113
+ })
114
+
115
+ const slice = (text: string, entity: Entity): string =>
116
+ text.slice(entity.offset, entity.offset + entity.length)
117
+
118
+ const isOwnHandle = (bot: BotIdentity, handle: string): boolean =>
119
+ handle.toLowerCase() === `@${bot.username}`.toLowerCase()
120
+
121
+ const entitiesOf = (message: Message): ReadonlyArray<Entity> => message.entities ?? []
122
+
123
+ const ownMentions = (bot: BotIdentity, message: Message, text: string): ReadonlyArray<Entity> =>
124
+ entitiesOf(message).filter(
125
+ (entity) => entity.type === "mention" && isOwnHandle(bot, slice(text, entity)),
126
+ )
127
+
128
+ type Command = { readonly name: string; readonly args: string }
129
+
130
+ /** `/name@bot args` at offset 0 is a command; a mid-text `/word` is just text. */
131
+ const command = (bot: BotIdentity, message: Message, text: string): Option.Option<Command> =>
132
+ Arr.findFirst(
133
+ entitiesOf(message),
134
+ (entity) => entity.type === "bot_command" && entity.offset === 0,
135
+ ).pipe(
136
+ Option.flatMap((entity) => {
137
+ const [name = "", suffix] = slice(text, entity).slice(1).split("@")
138
+ // A command aimed at another bot arrives under privacy-off, but is not ours.
139
+ return suffix !== undefined && !isOwnHandle(bot, `@${suffix}`)
140
+ ? Option.none()
141
+ : Option.some({ name, args: text.slice(entity.length).trim() })
142
+ }),
143
+ )
144
+
145
+ /** The bot's own `@handle` removed, right to left so earlier offsets stay valid. */
146
+ const withoutMention = (bot: BotIdentity, message: Message, text: string): string =>
147
+ Arr.reverse(ownMentions(bot, message, text))
148
+ .reduce(
149
+ (acc, entity) => acc.slice(0, entity.offset) + acc.slice(entity.offset + entity.length),
150
+ text,
151
+ )
152
+ .trim()
153
+
154
+ const isAddressed = (bot: BotIdentity, message: Message, text: string): boolean =>
155
+ message.chat.type === "private" ||
156
+ ownMentions(bot, message, text).length > 0 ||
157
+ message.reply_to_message?.from?.id === bot.id
158
+
159
+ const fromMessage = (
160
+ bot: BotIdentity,
161
+ message: Message,
162
+ raw: unknown,
163
+ ): Option.Option<InboundEvent> =>
164
+ Option.map(Option.fromNullishOr(message.from), (from) => {
165
+ const text = message.text ?? message.caption ?? ""
166
+ const author = UserId(String(from.id))
167
+ const at = conversation(message)
168
+ return Option.match(command(bot, message, text), {
169
+ onSome: ({ name, args }) =>
170
+ InboundEvent.Command({ conversation: at, name, args, author, raw }),
171
+ onNone: () =>
172
+ InboundEvent.Message({
173
+ conversation: at,
174
+ id: MessageId(String(message.message_id)),
175
+ author,
176
+ text: withoutMention(bot, message, text),
177
+ addressed: isAddressed(bot, message, text),
178
+ ...(message.reply_to_message !== undefined && {
179
+ replyTo: MessageId(String(message.reply_to_message.message_id)),
180
+ }),
181
+ raw,
182
+ }),
183
+ })
184
+ })
185
+
186
+ const fromReaction = (reaction: MessageReaction, raw: unknown): ReadonlyArray<InboundEvent> =>
187
+ Option.match(Option.fromNullishOr(reaction.user), {
188
+ onNone: () => [],
189
+ onSome: (user) =>
190
+ Arr.flatMap(reaction.new_reaction, (r) =>
191
+ Arr.fromNullishOr(r.emoji).map((emoji) =>
192
+ InboundEvent.Reaction({
193
+ conversation: conversation({ chat: reaction.chat }),
194
+ message: MessageId(String(reaction.message_id)),
195
+ emoji,
196
+ author: UserId(String(user.id)),
197
+ raw,
198
+ }),
199
+ ),
200
+ ),
201
+ })
202
+
203
+ const fromCallback = (query: CallbackQuery, raw: unknown): Option.Option<InboundEvent> =>
204
+ Option.map(Option.fromNullishOr(query.message), (message) =>
205
+ InboundEvent.Action({
206
+ conversation: conversation(message),
207
+ actionId: query.data ?? "",
208
+ author: UserId(String(query.from.id)),
209
+ raw,
210
+ }),
211
+ )
212
+
213
+ /**
214
+ * Everything one `Update` says to the agent. Edited messages are received
215
+ * (so the offset advances past them) but not re-delivered: a turn already
216
+ * answered them.
217
+ */
218
+ export const toEvents =
219
+ (bot: BotIdentity) =>
220
+ (update: Update): ReadonlyArray<InboundEvent> =>
221
+ Match.value(update).pipe(
222
+ Match.when({ message: Match.defined }, ({ message }) =>
223
+ Arr.fromOption(fromMessage(bot, message, update)),
224
+ ),
225
+ Match.when({ message_reaction: Match.defined }, ({ message_reaction }) =>
226
+ fromReaction(message_reaction, update),
227
+ ),
228
+ Match.when({ callback_query: Match.defined }, ({ callback_query }) =>
229
+ Arr.fromOption(fromCallback(callback_query, update)),
230
+ ),
231
+ Match.orElse(() => []),
232
+ )