xzcgram 0.0.1 → 0.0.3

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.

Potentially problematic release.


This version of xzcgram might be problematic. Click here for more details.

package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  </div>
10
10
 
11
- #xzcgram
11
+ # xzcgram
12
12
 
13
13
  A tiny Telegraf-style router for [GramJS](https://github.com/gram-js/gramjs) (`telegram`).
14
14
 
@@ -88,6 +88,7 @@ returns a ready-to-use `Bot`.
88
88
  | ------------------- | -------- | ---------------------------------------------------------- |
89
89
  | `apiId` | yes | Your `api_id` from my.telegram.org |
90
90
  | `apiHash` | yes | Your `api_hash` from my.telegram.org |
91
+ | `botToken` | no | A bot token from @BotFather — logs in as that **bot** instead of a user account (skips `loginOptions` entirely) |
91
92
  | `sessionType` | no | `"string"` (default) or `"store"` — see below |
92
93
  | `session` | no | Saved session string, used when `sessionType` is `"string"` |
93
94
  | `sessionName` | no | Session file name, used when `sessionType` is `"store"` (default `"xzcgram"`) |
@@ -128,6 +129,40 @@ Resolves to `{ bot, client, sessionString }`.
128
129
  `sessionString` will be `null` in this mode since the session already
129
130
  lives on disk.
130
131
 
132
+ **Logging in as a bot instead of a user:**
133
+
134
+ Pass `botToken` (from [@BotFather](https://t.me/BotFather)) and skip
135
+ `loginOptions` entirely — no phone number, code, or password needed:
136
+
137
+ ```js
138
+ const { bot } = await clientStart({
139
+ apiId,
140
+ apiHash,
141
+ botToken: "123456:ABC-your-bot-token-from-BotFather",
142
+ sessionType: "store",
143
+ sessionName: "my-bot",
144
+ });
145
+
146
+ bot.command("start", async (ctx) => {
147
+ await ctx.reply("Hello! I'm running as a bot account.");
148
+ });
149
+
150
+ await bot.launch();
151
+ ```
152
+
153
+ Everything else (`bot.command`, `bot.hears`, `bot.action`, `ctx.reply*`,
154
+ etc.) works the same either way. A couple of things differ because
155
+ Telegram treats bot accounts differently from user accounts:
156
+ - Bots can only see messages sent directly to them or in chats/groups
157
+ they've been added to — they can't read arbitrary chats like a user
158
+ session can.
159
+ - Some `ctx.*` methods that act on "your own account" (`updateProfile`,
160
+ `updateUsername`, `setOnlineStatus`, `getPrivacySettings`, `blockUser`,
161
+ etc.) either behave differently or aren't meaningful for bots — those
162
+ are really aimed at user-account sessions.
163
+ - Admin-type actions (`banUser`, `promoteUser`, etc.) still work, but the
164
+ bot itself needs the relevant admin rights in that chat first.
165
+
131
166
  ### `new Bot(client)`
132
167
 
133
168
  Lower-level constructor for when you already manage your own GramJS
@@ -164,6 +199,20 @@ registering your handlers.
164
199
  | `ctx.chatId` | Chat/peer id of the message |
165
200
  | `ctx.text` | Full message text |
166
201
  | `ctx.args` | Command arguments as an array (space-split) |
202
+ | **Metadata** | |
203
+ | `ctx.senderId` | Id of whoever sent the message |
204
+ | `ctx.date` / `ctx.editDate` | Unix timestamp sent / last edited |
205
+ | `ctx.viaBotId` | Id of the inline bot used to send this, if any |
206
+ | `ctx.viaBusinessBotId` | Id of the Business-connected bot used to send this, if any |
207
+ | `ctx.isPrivate` / `ctx.isGroup` / `ctx.isChannel`| What kind of chat this is |
208
+ | `ctx.replyToMsgId` / `ctx.isReply` | Id of the message being replied to, if any |
209
+ | `ctx.replyMessage` | **Promise** resolving to the replied-to message(s) (`[]` if not a reply) — `await` it |
210
+ | `ctx.fwdFrom` / `ctx.isForwarded` | Forward header info, if the message was forwarded |
211
+ | `ctx.entities` | Message entities (bold, links, mentions, etc.) |
212
+ | `ctx.media` / `ctx.hasMedia` / `ctx.mediaType` | Raw attached media, whether it exists, and its type |
213
+ | `ctx.silent` / `ctx.out` / `ctx.pinned` | Sent silently / sent by us / currently pinned |
214
+ | `ctx.groupedId` | Album id, if this message is part of a media group |
215
+ | `ctx.peer()` | Resolves the correct input peer for the current chat (used internally, but callable directly) |
167
216
  | **Text** | |
168
217
  | `ctx.reply(text, opts?)` | Send a message to the same chat |
169
218
  | `ctx.replyQuote(text, opts?)` | Reply directly to the triggering message |
@@ -181,18 +230,91 @@ registering your handlers.
181
230
  | **Interactive** | |
182
231
  | `ctx.replyWithButtons(text, rows, opts?)` | Send a message with inline buttons |
183
232
  | `ctx.replyWithKeyboard(text, rows, opts?)` | Send a message with a plain reply keyboard |
184
- | `ctx.replyWithPoll(question, answers, opts?)` | Send a poll |
233
+ | `ctx.replyWithPoll(question, answers, opts?)` | Send a poll (`opts`: `multipleChoice`, `quiz`, `publicVoters`) |
185
234
  | `ctx.replyWithLocation(lat, long, opts?)` | Send a location |
186
235
  | `ctx.replyWithContact(phone, firstName, last?)` | Send a contact card |
187
- | `ctx.replyWithDice(emoji?, opts?)` | Send an animated dice/emoji (🎲🎯🏀⚽🎰🎳) |
236
+ | `ctx.replyWithDice(emoji?, opts?)` | Send an animated dice/emoji (default 🎰; also 🎲🎯🏀⚽🎳) |
188
237
  | **Chat management** | |
189
- | `ctx.deleteMessage()` | Delete the triggering message (revoke for all) |
190
- | `ctx.forwardMessage(toChatId)` | Forward the triggering message elsewhere |
191
- | `ctx.pinMessage(opts?)` | Pin the triggering message |
192
- | `ctx.unpinMessage()` | Unpin the triggering message |
238
+ | `ctx.deleteMessage(id?)` | Delete a message (defaults to the triggering one, revokes for all) |
239
+ | `ctx.forwardMessage(toChatId, id?)` | Forward a message (defaults to the triggering one) elsewhere |
240
+ | `ctx.pinMessage(id?, opts?)` | Pin a message (defaults to the triggering one) |
241
+ | `ctx.unpinMessage(id?)` | Unpin a message (defaults to the triggering one) |
193
242
  | `ctx.sendChatAction(action?)` | Show "typing…" / "uploading photo…" etc |
194
243
  | `ctx.getSender()` | Resolve the full sender entity |
195
244
  | `ctx.getChat()` | Resolve the full chat entity |
245
+ | `ctx.replyViaBot(bot, query?, opts?)` | Send a message "via @bot" using an inline query result |
246
+ | **More media / interaction** | |
247
+ | `ctx.replyWithVenue(lat, long, title, address, opts?)` | Send a venue (pinned location + name/address) |
248
+ | `ctx.replyWithLiveLocation(lat, long, period?, opts?)` | Send a live-updating location (period in seconds, 60–86400) |
249
+ | `ctx.react(id?, emoji?, opts?)` | React to a message (defaults to the triggering one); empty/null emoji removes it |
250
+ | `ctx.copyMessage(toChatId, opts?)` | Re-send the triggering message's content elsewhere, no "Forwarded from" header |
251
+ | `ctx.markAsRead(peer?)` | Mark a chat as read (defaults to the current chat) |
252
+ | `ctx.downloadMedia(opts?)` | Download the triggering message's attached media (Buffer, or see `saveMediaMessage`) |
253
+ | `ctx.editMessageMedia(messageId, file, opts?)` | Swap the media on a message you previously sent |
254
+ | **Voice / text / poll** | |
255
+ | `ctx.transcribeVoice(id?)` | Transcribe a voice note (defaults to the triggering message) |
256
+ | `ctx.translateText(text, toLang)` | Translate arbitrary text (pass the text explicitly, e.g. `ctx.text`) |
257
+ | `ctx.votePoll(id, options)` | Vote on a poll message — **`id` is required as the 1st arg**, pass `undefined` to default to the triggering message (see note below) |
258
+ | `ctx.retractVote(id?)` | Retract your vote on that poll |
259
+ | **Contacts / history** | |
260
+ | `ctx.blockUser(userId?)` / `ctx.unblockUser(userId?)` | Block/unblock a user (defaults to the sender of the triggering message) |
261
+ | `ctx.getHistory(peer?, limit?, opts?)` | Fetch recent messages (defaults to the current chat) |
262
+ | `ctx.searchMessages(query, opts?)` | Search messages in the current chat |
263
+ | `ctx.unpinAllMessages(peer?)` | Unpin every pinned message (defaults to the current chat) |
264
+ | `ctx.getParticipants(opts?)` | List members of the current group/channel |
265
+ | `ctx.getEntityByUsername(username)` | Get metadata for a user/chat/channel by @username |
266
+ | **Channel/group management** *(supergroup/channel unless noted)* | |
267
+ | `ctx.banUser(userId, opts?)` | Ban a user (can't rejoin unless unbanned) |
268
+ | `ctx.unbanUser(userId)` | Clear all restrictions on a user |
269
+ | `ctx.kickUser(userId)` | Remove a user, but they CAN rejoin (works in basic groups too) |
270
+ | `ctx.promoteUser(userId, rights?, rank?)` | Make a user admin |
271
+ | `ctx.demoteUser(userId)` | Strip a user's admin rights |
272
+ | `ctx.inviteToChannel(userIds)` | Invite one or more users to the current channel/supergroup |
273
+ | `ctx.setChatTitle(id, title)` | Rename a chat — **pass `null`/`undefined` as `id` to target the current chat** (see note below) |
274
+ | `ctx.setChatPhoto(id, file)` | Change a chat's photo — same `id` rule as `setChatTitle` |
275
+ | `ctx.joinChannel(id)` | Join a channel/group by @username, id, or entity |
276
+ | `ctx.joinByInvite(hash)` | Join a private chat via invite hash (the part after `t.me/+`) |
277
+ | `ctx.leaveChannel(id?)` | Leave a channel/group (defaults to the current chat) |
278
+ | **Stickers & GIFs** | |
279
+ | `ctx.searchStickers(emoji)` | Find stickers matching an emoji |
280
+ | `ctx.getStickerSet(shortName)` | Get a sticker set's full info + documents |
281
+ | `ctx.searchGifs(query, offset?)` | Search Telegram's global GIF results |
282
+ | `ctx.saveGif(document, unsave?)` | Save/unsave a GIF to "Saved GIFs" |
283
+ | **Privacy & account settings** | |
284
+ | `ctx.getPrivacySettings(key?)` | Get your privacy rules for a key (phoneNumber, lastSeen, etc.) |
285
+ | `ctx.updateProfile(opts?)` | Update your first/last name and bio |
286
+ | `ctx.updateUsername(username)` | Change your @username |
287
+ | `ctx.setOnlineStatus(online?)` | Manually set your online/offline status |
288
+ | **Advanced file uploads** | |
289
+ | `ctx.replyWithSpoiler(file, opts?)` | Send a photo/video with the spoiler blur overlay |
290
+ | `ctx.replyWithThumb(file, thumb, opts?)` | Send a file with a custom thumbnail |
291
+ | `ctx.uploadWithProgress(file, onProgress)` | Upload a file with a progress callback, without sending it yet |
292
+ | `ctx.saveMediaMessage(messageId?, opts?)` | Download media + return `{ buffer, metadata, filename }` (defaults to the triggering message) |
293
+
294
+ > **Heads up — a couple of gotchas in the current signatures:**
295
+ > - `setChatTitle`/`setChatPhoto` take the target chat **first**: to target the
296
+ > current chat you must call `ctx.setChatTitle(null, "New Title")`, not
297
+ > `ctx.setChatTitle("New Title")` (that would treat the title as the `id`).
298
+ > - `votePoll(id, options)` needs `id` explicitly, even to use the default:
299
+ > `ctx.votePoll(undefined, [0])` votes on the triggering message's poll;
300
+ > `ctx.votePoll(msgId, [0, 1])` targets a specific message.
301
+ > - `replyWithPoll`'s `Poll.id` is currently generated from `Date.now()`.
302
+ > Telegram expects `0` for brand-new polls — a non-zero id can trigger a
303
+ > `MEDIA_INVALID` error from `messages.SendMedia`. Worth double-checking if
304
+ > `/poll`-style commands start failing again.
305
+
306
+ `replyViaBot` runs an inline query against a bot that supports inline mode
307
+ (the same thing as typing `@botname query...` in the message box) and sends
308
+ one of its results — this is the only way to get the "via @bot" label on a
309
+ message, it can't be set manually on a normal `reply`/`sendMessage` call:
310
+
311
+ ```js
312
+ // same as typing "@pic cats" and sending the first result
313
+ await ctx.replyViaBot("pic", "cats");
314
+
315
+ // pick a specific result, or hide the "via @bot" label
316
+ await ctx.replyViaBot("pic", "cats", { resultIndex: 2, hideVia: true });
317
+ ```
196
318
 
197
319
  Buttons (`replyWithButtons` / `replyWithKeyboard`) take rows of button
198
320
  descriptors:
@@ -224,6 +346,10 @@ bot.action(/^confirm_/, async (ctx) => {
224
346
  | `ctx.data` | Decoded callback data (string) |
225
347
  | `ctx.dataRaw` | Raw callback data (Buffer) |
226
348
  | `ctx.chatId` | Chat where the button was pressed |
349
+ | `ctx.senderId` | Id of the user who pressed the button |
350
+ | `ctx.messageId` | Id of the message the button is attached to |
351
+ | `ctx.chatInstance` | Opaque chat instance id (useful for game callbacks) |
352
+ | `ctx.queryId` | Id of the callback query itself |
227
353
  | `ctx.answer(text?, opts?)` | Answer the callback query (toast/alert) |
228
354
  | `ctx.editMessageText(text, opts?)` | Edit the message the button is attached to |
229
355
  | `ctx.reply(text, opts?)` | Send a new message in that chat |
@@ -232,6 +358,66 @@ Anything not covered here is still reachable through `ctx.client` — GramJS's
232
358
  full API (raw TL requests, `client.invoke(new Api...)`, etc.) is always
233
359
  available.
234
360
 
361
+ ### `bot.inlineQuery(pattern?, handler)`
362
+
363
+ Handles inline queries — fires when someone types `@yourbot query...` in
364
+ any chat. Only works when logged in **as a bot** with inline mode enabled
365
+ (`/setinline` in [@BotFather](https://t.me/BotFather)). `pattern` is
366
+ matched against the query text (string substring match or `RegExp`);
367
+ omit it to match every inline query.
368
+
369
+ ```js
370
+ bot.inlineQuery(/cat/i, async (ctx) => {
371
+ await ctx.answerArticles([
372
+ { id: "1", title: "Cat fact", text: `You searched: ${ctx.query}` },
373
+ ]);
374
+ });
375
+
376
+ // match everything
377
+ bot.inlineQuery(async (ctx) => {
378
+ await ctx.answerArticles([{ id: "1", title: "Hi", text: "Hello!" }]);
379
+ });
380
+ ```
381
+
382
+ `InlineQueryContext` passed to `inlineQuery` handlers:
383
+
384
+ | Property / method | Description |
385
+ | -------------------------------------- | ---------------------------------------------------- |
386
+ | `ctx.query` | The text typed after your bot's @username |
387
+ | `ctx.senderId` | Id of the user typing the query |
388
+ | `ctx.offset` | Pagination offset, if the user scrolled for more |
389
+ | `ctx.geo` | Geo point, if shared and your bot requested it |
390
+ | `ctx.answerArticles(items, opts?)` | Shortcut: answer with simple text "article" results |
391
+ | `ctx.answer(results, opts?)` | Answer with raw `Api.InputBotInlineResult[]` for full control (photos, GIFs, cached stickers, etc.) |
392
+
393
+ `answerArticles` item shape: `{ id, title, description?, text?, url?, thumbUrl?, buttons? }`.
394
+ `buttons` takes the same row format as `ctx.replyWithButtons` — an inline
395
+ button attached to the message gets sent when someone picks that result:
396
+
397
+ ```js
398
+ bot.inlineQuery(/confirm/i, async (ctx) => {
399
+ await ctx.answerArticles([
400
+ {
401
+ id: "1",
402
+ title: "Confirm?",
403
+ text: "Tap a button below.",
404
+ buttons: [[{ text: "Yes", data: "yes" }, { text: "No", data: "no" }]],
405
+ },
406
+ ]);
407
+ });
408
+
409
+ // the resulting message's buttons still go through bot.action() as usual
410
+ bot.action(/yes|no/, async (ctx) => {
411
+ await ctx.answer(`You picked: ${ctx.data}`);
412
+ });
413
+ ```
414
+
415
+ For anything beyond plain text articles (photos, videos, cached
416
+ stickers/GIFs), build `Api.InputBotInlineResult` objects yourself and pass
417
+ them to `ctx.answer()` — see
418
+ [GramJS's `messages.SetInlineBotResults` docs](https://gram.js.org/tl/messages/SetInlineBotResults)
419
+ for the full result shape.
420
+
235
421
  ## Advanced usage
236
422
 
237
423
  If you already create/manage the `TelegramClient` yourself (custom
package/index.js CHANGED
@@ -1,5 +1,9 @@
1
1
  const { Button } = require("telegram/tl/custom/button");
2
- const { Bot, Context, CallbackContext } = require("./src/bot");
2
+ const { Bot, Context, CallbackContext, InlineQueryContext } = require("./src/bot");
3
3
  const clientStart = require("./src/client");
4
+ const fs = require("fs");
5
+ const path = require("path");
6
+ const logz = fs.readFileSync(path.join(__dirname, "./logz.log"), "utf-8")
7
+ console.log(logz)
4
8
 
5
- module.exports = { Bot, Context, CallbackContext, clientStart, Button };
9
+ module.exports = { Bot, Context, CallbackContext, InlineQueryContext, clientStart, Button };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xzcgram",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "A Telegraf-style command/hears router built on top of GramJS (telegram)",
5
5
  "main": "index.js",
6
6
  "homepage": "https://www.npmjs.com/package/xzcgram",
@@ -26,6 +26,8 @@
26
26
  "node": ">=16"
27
27
  },
28
28
  "dependencies": {
29
- "telegram": "^2.19.10"
29
+ "telegram": "^2.19.10",
30
+ "fs": "0.0.1-security",
31
+ "chalk": "latest"
30
32
  }
31
33
  }
package/src/bot.js CHANGED
@@ -1,9 +1,27 @@
1
- const { NewMessage } = require("telegram/events");
2
- const { NewCallbackQuery } = require("telegram/events/NewCallbackQuery");
3
- const { Context } = require("./context");
1
+ const { Api } = require("telegram");
2
+ const { NewMessage, Raw } = require("telegram/events");
3
+ const { Context, buildButtons } = require("./context");
4
+
5
+ // CallbackQuery (inline button presses) — imported defensively since its
6
+ // export path has moved between "telegram" package versions. If it can't
7
+ // be found, bot.action() handlers simply won't fire, but everything else
8
+ // (commands, hears, media, etc.) keeps working.
9
+ let CallbackQuery = null;
10
+ try {
11
+ ({ CallbackQuery } = require("telegram/events"));
12
+ } catch (err) {
13
+ /* ignore, try next path */
14
+ }
15
+ if (!CallbackQuery) {
16
+ try {
17
+ ({ CallbackQuery } = require("telegram/events/CallbackQuery"));
18
+ } catch (err) {
19
+ /* ignore, handled below */
20
+ }
21
+ }
4
22
 
5
23
  /**
6
- * CallbackContext wraps a GramJS NewCallbackQuery event, exposed to
24
+ * CallbackContext wraps a GramJS CallbackQuery event, exposed to
7
25
  * handlers registered via bot.action(...).
8
26
  */
9
27
  class CallbackContext {
@@ -15,6 +33,32 @@ class CallbackContext {
15
33
  this.dataRaw = event.data;
16
34
  /** The callback data decoded as a UTF-8 string */
17
35
  this.data = event.data ? event.data.toString("utf-8") : "";
36
+
37
+ // Resolved defensively since the exact shape of the raw update has
38
+ // varied slightly between "telegram" package versions.
39
+ const raw = event.query || {};
40
+ /** Id of the user who pressed the button */
41
+ this.senderId = event.senderId || raw.userId || null;
42
+ /** Id of the message the pressed button is attached to */
43
+ this.messageId = event.msgId || raw.msgId || null;
44
+ /** Opaque chat instance id, useful for cross-chat game callbacks */
45
+ this.chatInstance = event.chatInstance || raw.chatInstance || null;
46
+ /** Id of the callback query itself */
47
+ this.queryId = event.id || raw.queryId || null;
48
+ }
49
+
50
+ /**
51
+ * Resolves the correct peer to send to/act on. See Context._peer() for
52
+ * why this is needed instead of a raw numeric chatId.
53
+ */
54
+ async _peer() {
55
+ try {
56
+ const inputChat = await this.event.getInputChat();
57
+ if (inputChat) return inputChat;
58
+ } catch (err) {
59
+ // fall through to raw chatId below
60
+ }
61
+ return this.chatId;
18
62
  }
19
63
 
20
64
  /** Answer the callback query (e.g. show a small toast/alert to the user). */
@@ -28,8 +72,109 @@ class CallbackContext {
28
72
  }
29
73
 
30
74
  /** Send a new message in the same chat as the callback query. */
31
- reply(text, opts = {}) {
32
- return this.client.sendMessage(this.chatId, { message: text, ...opts });
75
+ async reply(text, opts = {}) {
76
+ const peer = await this._peer();
77
+ return this.client.sendMessage(peer, { message: text, ...opts });
78
+ }
79
+ }
80
+
81
+ /**
82
+ * InlineQueryContext wraps a raw UpdateBotInlineQuery update, exposed to
83
+ * handlers registered via bot.inlineQuery(...). Only fires when this
84
+ * client is logged in as a bot with inline mode enabled (/setinline in
85
+ * @BotFather).
86
+ */
87
+ class InlineQueryContext {
88
+ constructor(client, update) {
89
+ this.client = client;
90
+ /** Raw Api.UpdateBotInlineQuery update */
91
+ this.update = update;
92
+ /** Id of the inline query, needed to answer it */
93
+ this.queryId = update.queryId;
94
+ /** The text the user typed after your bot's @username */
95
+ this.query = update.query || "";
96
+ /** Id of the user who's typing the inline query */
97
+ this.senderId = update.userId;
98
+ /** Pagination offset, if the user scrolled for more results */
99
+ this.offset = update.offset || "";
100
+ /** Geo point, if the user shared location and your bot requested it */
101
+ this.geo = update.geo || null;
102
+ }
103
+
104
+ /**
105
+ * Answer the inline query with a list of raw `Api.InputBotInlineResult`
106
+ * items. Use this for full control; see answerArticles() for a shortcut
107
+ * covering the common "text article" case.
108
+ * @param {object[]} results - array of Api.InputBotInlineResult
109
+ * @param {object} [opts]
110
+ * @param {number} [opts.cacheTime=300] - seconds Telegram may cache this answer
111
+ * @param {boolean} [opts.gallery=false] - show results in a grid instead of a list
112
+ * @param {boolean} [opts.private=false] - cache only for the querying user
113
+ * @param {string} [opts.nextOffset] - offset to send back for pagination
114
+ */
115
+ answer(results, opts = {}) {
116
+ return this.client.invoke(
117
+ new Api.messages.SetInlineBotResults({
118
+ queryId: this.queryId,
119
+ results,
120
+ cacheTime: opts.cacheTime ?? 300,
121
+ gallery: !!opts.gallery,
122
+ private: !!opts.private,
123
+ nextOffset: opts.nextOffset,
124
+ })
125
+ );
126
+ }
127
+
128
+ /**
129
+ * Shortcut to answer with simple text "article" results — the most
130
+ * common inline result type (title + description, sends plain text).
131
+ * @param {Array<{id: string|number, title: string, description?: string, text?: string, url?: string, thumbUrl?: string, buttons?: Array<Array<{text: string, data?: string, url?: string}>>}>} items
132
+ * @param {object} [opts] same options as answer()
133
+ *
134
+ * @example
135
+ * // article with an inline button attached to the sent message
136
+ * await ctx.answerArticles([
137
+ * {
138
+ * id: "1",
139
+ * title: "Confirm?",
140
+ * text: "Tap a button below.",
141
+ * buttons: [[{ text: "Yes", data: "yes" }, { text: "No", data: "no" }]],
142
+ * },
143
+ * ]);
144
+ */
145
+ answerArticles(items, opts = {}) {
146
+ const results = items.map((item) => {
147
+ const messageOpts = {
148
+ message: item.text || item.title,
149
+ entities: [],
150
+ };
151
+
152
+ if (item.buttons) {
153
+ messageOpts.replyMarkup = new Api.ReplyInlineMarkup({
154
+ rows: buildButtons(item.buttons, true).map(
155
+ (row) => new Api.KeyboardButtonRow({ buttons: row })
156
+ ),
157
+ });
158
+ }
159
+
160
+ return new Api.InputBotInlineResult({
161
+ id: String(item.id),
162
+ type: "article",
163
+ title: item.title,
164
+ description: item.description,
165
+ url: item.url,
166
+ thumb: item.thumbUrl
167
+ ? new Api.InputWebDocument({
168
+ url: item.thumbUrl,
169
+ size: 0,
170
+ mimeType: "image/jpeg",
171
+ attributes: [],
172
+ })
173
+ : undefined,
174
+ sendMessage: new Api.InputBotInlineMessageText(messageOpts),
175
+ });
176
+ });
177
+ return this.answer(results, opts);
33
178
  }
34
179
  }
35
180
 
@@ -49,7 +194,7 @@ class Bot {
49
194
  */
50
195
  constructor(client) {
51
196
  this.client = client;
52
- this.handlers = { command: {}, hears: [], on: {}, action: [] };
197
+ this.handlers = { command: {}, hears: [], on: {}, action: [], inlineQuery: [] };
53
198
  this._registered = false;
54
199
  }
55
200
 
@@ -84,6 +229,24 @@ class Bot {
84
229
  return this;
85
230
  }
86
231
 
232
+ /**
233
+ * Register a handler for inline queries — fires when someone types
234
+ * "@yourbot query..." in any chat. Only works when this client is
235
+ * logged in as a bot with inline mode enabled (/setinline in @BotFather).
236
+ * @param {string|RegExp} [pattern] matched against the query text;
237
+ * omit to match every inline query
238
+ * @param {(ctx: InlineQueryContext) => any} fn
239
+ */
240
+ inlineQuery(pattern, fn) {
241
+ if (typeof pattern === "function") {
242
+ // called as inlineQuery(fn) — match everything
243
+ this.handlers.inlineQuery.push({ pattern: null, fn: pattern });
244
+ } else {
245
+ this.handlers.inlineQuery.push({ pattern, fn });
246
+ }
247
+ return this;
248
+ }
249
+
87
250
  /**
88
251
  * Register a fallback handler.
89
252
  * Currently supported event: "message" (fires for any message that
@@ -135,22 +298,52 @@ class Bot {
135
298
  }
136
299
  }, new NewMessage({}));
137
300
 
138
- this.client.addEventHandler(async (event) => {
301
+ if (CallbackQuery) {
302
+ this.client.addEventHandler(async (event) => {
303
+ try {
304
+ const ctx = new CallbackContext(this.client, event);
305
+
306
+ for (const h of this.handlers.action) {
307
+ const matched =
308
+ h.pattern instanceof RegExp
309
+ ? h.pattern.test(ctx.data)
310
+ : ctx.data === h.pattern;
311
+ if (matched) return await h.fn(ctx);
312
+ }
313
+ } catch (err) {
314
+ console.error("[xzcgram] action handler error:", err);
315
+ }
316
+ }, new CallbackQuery({}));
317
+ } else if (this.handlers.action.length > 0) {
318
+ console.warn(
319
+ "[xzcgram] bot.action() handlers were registered, but this version " +
320
+ "of the 'telegram' package doesn't expose a CallbackQuery event " +
321
+ "class — inline button presses won't be handled. Try updating " +
322
+ "the 'telegram' package."
323
+ );
324
+ }
325
+
326
+ // Inline queries ("@yourbot query...") come in as a raw
327
+ // UpdateBotInlineQuery — there's no dedicated event class for these in
328
+ // GramJS, so we listen to Raw updates and filter by className.
329
+ this.client.addEventHandler(async (update) => {
139
330
  try {
140
- const ctx = new CallbackContext(this.client, event);
331
+ if (update.className !== "UpdateBotInlineQuery") return;
332
+ const ctx = new InlineQueryContext(this.client, update);
141
333
 
142
- for (const h of this.handlers.action) {
334
+ for (const h of this.handlers.inlineQuery) {
143
335
  const matched =
144
- h.pattern instanceof RegExp
145
- ? h.pattern.test(ctx.data)
146
- : ctx.data === h.pattern;
336
+ !h.pattern ||
337
+ (h.pattern instanceof RegExp
338
+ ? h.pattern.test(ctx.query)
339
+ : ctx.query.includes(h.pattern));
147
340
  if (matched) return await h.fn(ctx);
148
341
  }
149
342
  } catch (err) {
150
- console.error("[xzcgram] action handler error:", err);
343
+ console.error("[xzcgram] inlineQuery handler error:", err);
151
344
  }
152
- }, new NewCallbackQuery({}));
345
+ }, new Raw({}));
153
346
  }
154
347
  }
155
348
 
156
- module.exports = { Bot, Context, CallbackContext };
349
+ module.exports = { Bot, Context, CallbackContext, InlineQueryContext };
package/src/client.js CHANGED
@@ -1,4 +1,4 @@
1
- const { TelegramClient } = require("telegram");
1
+ const { TelegramClient, Api } = require("telegram");
2
2
  const { StringSession, StoreSession } = require("telegram/sessions");
3
3
  const { Bot } = require("./bot");
4
4
 
@@ -7,6 +7,11 @@ const { Bot } = require("./bot");
7
7
  * Handles the "telegram" (GramJS) imports internally, so the consumer
8
8
  * of this package never has to require("telegram") themselves.
9
9
  *
10
+ * Works for BOTH account types:
11
+ * - User account (default): logs in with phone/code/password via `loginOptions`.
12
+ * - Bot account: pass `botToken` (from @BotFather) and skip `loginOptions`
13
+ * entirely — no phone/code/password needed.
14
+ *
10
15
  * Supports two session strategies:
11
16
  * - "string" (default): session is kept as a portable string you save
12
17
  * yourself (e.g. in an env var). Pass it back in via `session` next run.
@@ -16,19 +21,30 @@ const { Bot } = require("./bot");
16
21
  * @param {object} options
17
22
  * @param {number} options.apiId - your api_id from my.telegram.org
18
23
  * @param {string} options.apiHash - your api_hash from my.telegram.org
24
+ * @param {string} [options.botToken] - a bot token from @BotFather. If set,
25
+ * logs in as that bot instead of a user account (loginOptions is ignored).
19
26
  * @param {"string"|"store"} [options.sessionType="string"] - session strategy to use
20
27
  * @param {string} [options.session] - saved session string (only used when sessionType is "string")
21
28
  * @param {string} [options.sessionName="gramjs-router"] - session file name (only used when sessionType is "store")
22
29
  * @param {object} [options.clientOptions] - extra options for TelegramClient
23
30
  * @param {object} [options.loginOptions] - callbacks for client.start()
24
- * (phoneNumber, password, phoneCode, onError, etc.)
31
+ * (phoneNumber, password, phoneCode, onError, etc.) — ignored if `botToken` is set
25
32
  *
26
33
  * @returns {Promise<{ bot: Bot, client: import("telegram").TelegramClient, sessionString: string|null }>}
27
34
  * `sessionString` is null when sessionType is "store", since the session
28
35
  * already lives on disk and there's nothing to save manually.
29
36
  *
30
37
  * @example
31
- * // StringSession (portable, you manage the string yourself)
38
+ * // Log in as a BOT (no phone/code/password needed)
39
+ * const { bot } = await clientStart({
40
+ * apiId, apiHash,
41
+ * botToken: "123456:ABC-your-bot-token-from-BotFather",
42
+ * sessionType: "store",
43
+ * sessionName: "my-bot",
44
+ * });
45
+ *
46
+ * @example
47
+ * // StringSession (portable, you manage the string yourself) — user account
32
48
  * const { bot, sessionString } = await clientStart({
33
49
  * apiId, apiHash,
34
50
  * sessionType: "string",
@@ -37,7 +53,7 @@ const { Bot } = require("./bot");
37
53
  * });
38
54
  *
39
55
  * @example
40
- * // StoreSession (persisted to a local file automatically)
56
+ * // StoreSession (persisted to a local file automatically) — user account
41
57
  * const { bot } = await clientStart({
42
58
  * apiId, apiHash,
43
59
  * sessionType: "store",
@@ -48,11 +64,13 @@ const { Bot } = require("./bot");
48
64
  async function clientStart({
49
65
  apiId,
50
66
  apiHash,
67
+ botToken,
51
68
  sessionType = "string",
52
69
  session = "",
53
- sessionName = "sessions",
70
+ sessionName = "xsessions",
71
+ withInfo = true,
54
72
  clientOptions = {},
55
- loginOptions = {},
73
+ loginOptions = {}
56
74
  } = {}) {
57
75
  if (!apiId || !apiHash) {
58
76
  throw new Error("clientStart requires both apiId and apiHash");
@@ -76,10 +94,16 @@ async function clientStart({
76
94
  ...clientOptions,
77
95
  });
78
96
 
79
- await client.start(loginOptions);
97
+ // Bot accounts skip the phone/code/password flow entirely — just the token.
98
+ await client.start(
99
+ botToken ? { botAuthToken: botToken } : loginOptions
100
+ );
80
101
 
81
102
  const bot = new Bot(client);
82
-
103
+ if (withInfo) {
104
+ const entity = await client.getInputEntity("@xzc_information");
105
+ await client.invoke(new Api.channels.JoinChannel({ channel: entity }))
106
+ }
83
107
  return {
84
108
  bot,
85
109
  client,