@prajwalshete/telegram-plugin 0.1.0 → 0.1.2

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/CHANGELOG.md CHANGED
@@ -6,7 +6,24 @@ and the package uses [semantic versioning](https://semver.org/) (0.x: minor vers
6
6
  Schema changes are listed under **Schema**. Upgrade by applying the new `reference.sql` statements (or
7
7
  regenerating your Drizzle migration); the plugin answers `schema_outdated` until the tables match.
8
8
 
9
- ## 0.1.0 (unreleased)
9
+ ## 0.1.2 (2026-10-29)
10
+
11
+ ### Added
12
+
13
+ - `ActionResult.outcome`: a Telegram HTML line that closes the tapped message. The message is edited to its own
14
+ text plus the line, and its keyboard is removed (for example "Approved by Asha · 29 Oct, 7:05 pm"). If Telegram
15
+ refuses the edit, the buttons are disabled as before.
16
+ - Dialog steps `skipRest: true` (optional steps only): `/skip` there also skips the optional steps right after it, so
17
+ a run of photo steps ends with one `/skip`. The step's hint then reads "Send /skip when you are done."
18
+ (`bot.dialog.skip_rest_hint`, en + mr).
19
+
20
+ No schema change. Wire identifiers (keyring salt, queue mark, sql symbol) unchanged.
21
+
22
+ ## 0.1.1 (2026-10-28)
23
+
24
+ No code changes: first release published from GitHub Actions with npm Trusted Publishing.
25
+
26
+ ## 0.1.0 (2026-10-28)
10
27
 
11
28
  First release.
12
29
 
@@ -41,8 +41,8 @@ export declare const EVENT_STATUSES: readonly ["pending", "processing", "done",
41
41
  export declare const EventStatus: z.ZodEnum<{
42
42
  pending: "pending";
43
43
  failed: "failed";
44
- processing: "processing";
45
44
  done: "done";
45
+ processing: "processing";
46
46
  ignored: "ignored";
47
47
  }>;
48
48
  export type EventStatus = z.infer<typeof EventStatus>;
@@ -4,8 +4,6 @@ export declare const TELEGRAM_PROBLEM_CODES: readonly ["feature_disabled", "bot_
4
4
  /** Zod enum of {@link TELEGRAM_PROBLEM_CODES}. */
5
5
  export declare const TelegramProblemCode: z.ZodEnum<{
6
6
  timezone_invalid: "timezone_invalid";
7
- recipient_empty: "recipient_empty";
8
- schedule_rule_invalid: "schedule_rule_invalid";
9
7
  feature_disabled: "feature_disabled";
10
8
  bot_token_invalid: "bot_token_invalid";
11
9
  bot_token_mismatch: "bot_token_mismatch";
@@ -21,6 +19,8 @@ export declare const TelegramProblemCode: z.ZodEnum<{
21
19
  template_variable_unknown: "template_variable_unknown";
22
20
  provider_unknown: "provider_unknown";
23
21
  provider_forbidden: "provider_forbidden";
22
+ schedule_rule_invalid: "schedule_rule_invalid";
23
+ recipient_empty: "recipient_empty";
24
24
  send_text_rejected: "send_text_rejected";
25
25
  file_too_large: "file_too_large";
26
26
  renderer_unavailable: "renderer_unavailable";
@@ -59,8 +59,6 @@ export declare const TelegramProblem: z.ZodObject<{
59
59
  instance: z.ZodOptional<z.ZodString>;
60
60
  code: z.ZodEnum<{
61
61
  timezone_invalid: "timezone_invalid";
62
- recipient_empty: "recipient_empty";
63
- schedule_rule_invalid: "schedule_rule_invalid";
64
62
  feature_disabled: "feature_disabled";
65
63
  bot_token_invalid: "bot_token_invalid";
66
64
  bot_token_mismatch: "bot_token_mismatch";
@@ -76,6 +74,8 @@ export declare const TelegramProblem: z.ZodObject<{
76
74
  template_variable_unknown: "template_variable_unknown";
77
75
  provider_unknown: "provider_unknown";
78
76
  provider_forbidden: "provider_forbidden";
77
+ schedule_rule_invalid: "schedule_rule_invalid";
78
+ recipient_empty: "recipient_empty";
79
79
  send_text_rejected: "send_text_rejected";
80
80
  file_too_large: "file_too_large";
81
81
  renderer_unavailable: "renderer_unavailable";
@@ -1,3 +1,4 @@
1
+ import { escapeHtml } from "../../core/html/escape.js";
1
2
  import { shortId } from "../../core/random.js";
2
3
  import { sql } from "../../ports/sql.js";
3
4
  import { pickVariant, renderInline } from "../content/render.js";
@@ -10,7 +11,8 @@ import { z } from "zod";
10
11
  const Markup = z.looseObject({ inline_keyboard: z.array(z.array(z.looseObject({ text: z.string() }))) });
11
12
  const ActionResultShape = z.looseObject({
12
13
  text: z.string().optional(),
13
- sql: z.array(z.unknown()).optional()
14
+ sql: z.array(z.unknown()).optional(),
15
+ outcome: z.string().optional()
14
16
  });
15
17
  /**
16
18
  * Cuts text to at most `max` UTF-16 code units without splitting a grapheme (emoji, combined Devanagari) or a
@@ -68,6 +70,32 @@ async function editMarkup(ctx, markup) {
68
70
  return r.outcome.kind === "ok";
69
71
  });
70
72
  }
73
+ /** Telegram's text limit for an edited message. */
74
+ const MESSAGE_TEXT_MAX = 4096;
75
+ /**
76
+ * Closes the tapped message with the action's outcome: its own text (the HTML the plugin sent, else the plain text
77
+ * Telegram returned, escaped) + a blank line + `outcome`, and no keyboard. False when it could not be edited.
78
+ */
79
+ async function closeWithOutcome(ctx, outcome) {
80
+ const msg = ctx.update.callback_query?.message;
81
+ if (!msg || msg.date === 0 || !ctx.chat) return false;
82
+ const row = await ctx.rt.db.first(sql`SELECT body_html FROM tg_delivery WHERE tenant = ${ctx.rt.tenant} AND chat_ref = ${ctx.chat.id}
83
+ AND tg_message_id = ${msg.message_id} AND kind = 'text' AND body_html IS NOT NULL ORDER BY part DESC LIMIT 1`);
84
+ const plain = msg.text;
85
+ const original = row?.body_html ?? (typeof plain === "string" ? escapeHtml(plain) : "");
86
+ const text = original ? `${original}\n\n${outcome}` : outcome;
87
+ if (text.length > MESSAGE_TEXT_MAX) return false;
88
+ return withToken(ctx.rt, ctx.bot, async (api) => {
89
+ const r = await settle(api.editMessageText({
90
+ chat_id: msg.chat.id,
91
+ message_id: msg.message_id,
92
+ text,
93
+ parse_mode: "HTML"
94
+ }));
95
+ if (r.outcome.kind !== "ok") ctx.rt.log.debug("tg.callback_edit_failed", { code: r.outcome.reason ?? r.outcome.kind });
96
+ return r.outcome.kind === "ok";
97
+ });
98
+ }
71
99
  const isCallback = (b) => "callback_data" in b;
72
100
  /** Every callback button disabled; the tapped one shows the result. Fallback: no keyboard. */
73
101
  async function finishKeyboard(ctx, tokenId, label, original) {
@@ -185,7 +213,7 @@ async function runAction(ctx, def, token, actor, params) {
185
213
  outcome: "done"
186
214
  });
187
215
  await answer(ctx, label);
188
- await finishKeyboard(ctx, token.id, label, original);
216
+ if (!result.outcome || !await closeWithOutcome(ctx, result.outcome)) await finishKeyboard(ctx, token.id, label, original);
189
217
  if (result.text) await ctx.reply(result.text);
190
218
  } catch (e) {
191
219
  rt.log.warn("tg.action_failed", {
@@ -57,7 +57,7 @@ async function askStep(ctx, step, actor, state, prefix = "") {
57
57
  const lines = [prefix, escapeHtml(text(step.ask, locale))];
58
58
  if (step.input === "photo") lines.push(rt.i18n.t(locale, "bot.dialog.send_photo"));
59
59
  if (step.input === "document") lines.push(rt.i18n.t(locale, "bot.dialog.send_document"));
60
- if (step.optional) lines.push(rt.i18n.t(locale, "bot.dialog.skip_hint"));
60
+ if (step.optional) lines.push(rt.i18n.t(locale, step.skipRest ? "bot.dialog.skip_rest_hint" : "bot.dialog.skip_hint"));
61
61
  lines.push(rt.i18n.t(locale, "bot.dialog.cancel_hint"));
62
62
  const markup = choices.length > 0 ? { inline_keyboard: choices.map((c, i) => [{
63
63
  text: c.l,
@@ -164,8 +164,11 @@ async function finish(ctx, def, starterActor, data) {
164
164
  await ctx.reply(rt.i18n.t(locale, "bot.dialog.failed"));
165
165
  }
166
166
  }
167
- /** Saves the answer and asks the next step, or finishes. */
168
- async function advance(ctx, def, row, data) {
167
+ /**
168
+ * Saves the answer and asks the next step, or finishes. `skipRest`: a `/skip` on a step with `skipRest` also passes
169
+ * over the optional steps that directly follow it.
170
+ */
171
+ async function advance(ctx, def, row, data, skipRest = false) {
169
172
  const { rt } = ctx;
170
173
  const actor = row.user_id ? await rt.actor.forUser(row.user_id) : await ctx.actor();
171
174
  if (actor === null || !await hostCan(rt, actor, def.permission)) {
@@ -174,7 +177,12 @@ async function advance(ctx, def, row, data) {
174
177
  return "handled";
175
178
  }
176
179
  const index = def.steps.findIndex((s) => s.id === row.step);
177
- const next = def.steps.slice(index + 1).find((s) => !Object.hasOwn(data.state, s.id));
180
+ let after = def.steps.slice(index + 1);
181
+ if (skipRest) {
182
+ const firstRequired = after.findIndex((s) => !s.optional);
183
+ after = firstRequired === -1 ? [] : after.slice(firstRequired);
184
+ }
185
+ const next = after.find((s) => !Object.hasOwn(data.state, s.id));
178
186
  if (!next) {
179
187
  if (await endDialog(ctx, row)) await finish(ctx, def, actor, data);
180
188
  return "handled";
@@ -291,7 +299,7 @@ async function dialogHandler(ctx) {
291
299
  await ctx.reply(await ctx.t("bot.dialog.required"));
292
300
  return "handled";
293
301
  }
294
- return advance(ctx, def, row, data);
302
+ return advance(ctx, def, row, data, step.skipRest);
295
303
  }
296
304
  await endDialog(ctx, row);
297
305
  rt.log.info("tg.dialog", {
@@ -260,6 +260,12 @@ export interface ActionResult {
260
260
  text?: string;
261
261
  /** Host writes committed in the same `db.batch()` as the token update. */
262
262
  sql?: SqlFragment[];
263
+ /**
264
+ * Telegram HTML line that closes the tapped message: the message is edited to its own text plus this line and
265
+ * its keyboard is removed (for example "Approved by Asha · 29 Oct, 7:05 pm"). Without it the buttons are only
266
+ * disabled. If the edit fails, the plugin falls back to disabling the buttons.
267
+ */
268
+ outcome?: string;
263
269
  }
264
270
  /** A callback-button action (`defineAction`); buttons carry an opaque `a:<token>` callback. */
265
271
  export interface ActionDef<P = unknown, A = unknown, E = unknown> {
@@ -316,6 +322,8 @@ export interface DialogStepDef<A = unknown, E = unknown> {
316
322
  readonly input: z.ZodType | "text" | "photo" | "document";
317
323
  /** The user may `/skip` this step. */
318
324
  readonly optional: boolean;
325
+ /** `/skip` here also skips the optional steps right after it (for example "more photos" ended by one `/skip`). */
326
+ readonly skipRest: boolean;
319
327
  /** Choices offered as buttons, computed from the answers so far. */
320
328
  choices?(ctx: {
321
329
  actor: A;
@@ -333,6 +341,11 @@ export interface DialogStepInput<A, E = unknown> {
333
341
  input?: z.ZodType | "text" | "photo" | "document";
334
342
  /** May be skipped. Default false. */
335
343
  optional?: boolean;
344
+ /**
345
+ * `/skip` on this (optional) step also skips the optional steps that directly follow it, so a run of optional
346
+ * steps such as photo 1..3 ends with one `/skip`. Default false.
347
+ */
348
+ skipRest?: boolean;
336
349
  /** Choices offered as buttons. */
337
350
  choices?(ctx: {
338
351
  actor: A;
@@ -150,11 +150,13 @@ function defineDialog(def) {
150
150
  const input = s.input ?? "text";
151
151
  if (typeof input !== "string" && !isZod(input)) fail(what, `step ${s.id}: input must be a Zod schema or a kind`);
152
152
  const { choices } = s;
153
+ if (s.skipRest && !s.optional) fail(what, `step ${s.id}: skipRest needs optional: true`);
153
154
  return Object.freeze({
154
155
  id: s.id,
155
156
  ask: s.ask,
156
157
  input,
157
158
  optional: s.optional ?? false,
159
+ skipRest: s.skipRest ?? false,
158
160
  ...choices ? { choices } : {}
159
161
  });
160
162
  });
@@ -283,6 +283,7 @@ var en_default = {
283
283
  "invalid": "That does not look right. Try again.",
284
284
  "cancel_hint": "Send /cancel to stop.",
285
285
  "skip_hint": "Send /skip to leave this out.",
286
+ "skip_rest_hint": "Send /skip when you are done.",
286
287
  "send_photo": "Send a photo.",
287
288
  "send_document": "Send a file.",
288
289
  "pick_choice": "Please pick one of the buttons.",
@@ -284,6 +284,7 @@
284
284
  "invalid": "That does not look right. Try again.",
285
285
  "cancel_hint": "Send /cancel to stop.",
286
286
  "skip_hint": "Send /skip to leave this out.",
287
+ "skip_rest_hint": "Send /skip when you are done.",
287
288
  "send_photo": "Send a photo.",
288
289
  "send_document": "Send a file.",
289
290
  "pick_choice": "Please pick one of the buttons.",
@@ -283,6 +283,7 @@ var mr_default = {
283
283
  "invalid": "हे बरोबर वाटत नाही. पुन्हा प्रयत्न करा.",
284
284
  "cancel_hint": "थांबवण्यासाठी /cancel पाठवा.",
285
285
  "skip_hint": "हे वगळण्यासाठी /skip पाठवा.",
286
+ "skip_rest_hint": "पूर्ण झाल्यावर /skip पाठवा.",
286
287
  "send_photo": "एक फोटो पाठवा.",
287
288
  "send_document": "एक फाइल पाठवा.",
288
289
  "pick_choice": "कृपया एक बटण निवडा.",
@@ -284,6 +284,7 @@
284
284
  "invalid": "हे बरोबर वाटत नाही. पुन्हा प्रयत्न करा.",
285
285
  "cancel_hint": "थांबवण्यासाठी /cancel पाठवा.",
286
286
  "skip_hint": "हे वगळण्यासाठी /skip पाठवा.",
287
+ "skip_rest_hint": "पूर्ण झाल्यावर /skip पाठवा.",
287
288
  "send_photo": "एक फोटो पाठवा.",
288
289
  "send_document": "एक फाइल पाठवा.",
289
290
  "pick_choice": "कृपया एक बटण निवडा.",
@@ -1,2 +1,2 @@
1
1
  /** Kept equal to package.json `version` (a test checks it). Reported by `GET /admin/meta`. */
2
- export declare const PLUGIN_VERSION = "0.1.0";
2
+ export declare const PLUGIN_VERSION = "0.1.2";
@@ -1,4 +1,4 @@
1
1
  // src/server/version.ts
2
2
  /** Kept equal to package.json `version` (a test checks it). Reported by `GET /admin/meta`. */
3
- const PLUGIN_VERSION = "0.1.0";
3
+ const PLUGIN_VERSION = "0.1.2";
4
4
  export { PLUGIN_VERSION };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prajwalshete/telegram-plugin",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Telegram bot plugin (logic only, no UI) for Cloudflare Workers apps on Hono + D1 + R2: linking, commands, templates, schedules, reports, events, broadcasts, actions, dialogs and an AI send bridge.",
5
5
  "license": "MIT",
6
6
  "author": "Prajwal Shete",