@xbibzlibrary/telebibz 3.0.1 → 3.1.1

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.
@@ -1,4 +1,5 @@
1
- // examples/03-wizard.js — form pendaftaran tanya-jawab, 0 boilerplate.
1
+ // examples/03-wizard.js — form pendaftaran tanya-jawab + TOMBOL + mode edit/delete.
2
+ // BOT_TOKEN=123:abc node examples/03-wizard.js
2
3
  'use strict';
3
4
 
4
5
  const { TeleBibz } = require('..');
@@ -6,22 +7,50 @@ const { TeleBibz } = require('..');
6
7
  const bot = new TeleBibz(process.env.BOT_TOKEN);
7
8
 
8
9
  bot.wizard('daftar', {
10
+ // mode tampilan pertanyaan:
11
+ // 'send' → tiap pertanyaan jadi pesan baru (default)
12
+ // 'edit' → SATU pesan diedit terus dari awal sampai akhir
13
+ // 'delete' → pesan lama dihapus dulu, baru tanya berikutnya
14
+ mode: 'edit',
15
+
9
16
  steps: [
10
17
  { key: 'nama', ask: '1️⃣ Siapa nama lengkapmu?' },
18
+
19
+ {
20
+ // TOMBOL reply keyboard — user tinggal ketuk, tak perlu mengetik
21
+ key: 'jk',
22
+ ask: '2️⃣ Jenis kelamin?',
23
+ buttons: ['👨 Laki-laki', '👩 Perempuan'],
24
+ onlyButtons: true, // tolak ketikan bebas
25
+ },
26
+
27
+ {
28
+ // TOMBOL inline (callback) — nilai bisa beda dari label,
29
+ // dan pesan langsung diedit ke langkah berikutnya
30
+ key: 'domisili',
31
+ ask: '3️⃣ Domisili pulau mana?',
32
+ inline: true,
33
+ onlyButtons: true,
34
+ buttons: [
35
+ [{ text: '🌋 Jawa', value: 'jawa' }, { text: '🌴 Sumatera', value: 'sumatera' }],
36
+ [{ text: '🏝️ Lainnya', value: 'lainnya' }],
37
+ ],
38
+ },
39
+
11
40
  {
12
41
  key: 'umur',
13
- ask: '2️⃣ Umur berapa?',
42
+ ask: '4️⃣ Umur berapa? (ketik "batal" untuk berhenti)',
14
43
  parse: (t) => parseInt(t, 10),
15
44
  validate: (n) => (Number.isFinite(n) && n > 0 && n < 120 ? null : 'Umur tidak valid — ketik angka saja ya:'),
16
45
  },
17
- {
18
- key: 'kota',
19
- ask: '3️⃣ Tinggal di kota mana? (ketik "batal" untuk berhenti)',
20
- validate: (t) => (t && t.length >= 3 ? null : 'Nama kota minimal 3 huruf:'),
21
- },
22
46
  ],
47
+
48
+ // cleanup bawaan: pesan tanya terakhir otomatis dibereskan,
49
+ // reply keyboard otomatis disingkirkan (nonaktif: removeKeyboard: false)
23
50
  done: async (ans, ctx) => {
24
- await ctx.reply(`✅ Terdaftar!\nNama : ${ans.nama}\nUmur : ${ans.umur}\nKota : ${ans.kota}`);
51
+ await ctx.reply(
52
+ `✅ Terdaftar!\nNama : ${ans.nama}\nJK : ${ans.jk}\nDomisili : ${ans.domisili}\nUmur : ${ans.umur}`,
53
+ );
25
54
  },
26
55
  onCancel: (ctx) => ctx.reply('👋 Pendaftaran dibatalkan.'),
27
56
  });
package/index.d.ts CHANGED
@@ -6,18 +6,36 @@ declare module '@xbibzlibrary/telebibz' {
6
6
  type Color = 'danger' | 'success' | 'primary';
7
7
  type Handler = (ctx: Context) => unknown;
8
8
 
9
+ type WizardMode = 'send' | 'edit' | 'delete';
10
+ type WizardButton = string | { text: string; value?: unknown };
9
11
  interface WizardStep {
10
12
  key: string;
11
13
  ask: string | ((ctx: Context) => string | Promise<string>);
12
14
  parse?: (text: string, ctx: Context) => unknown | Promise<unknown>;
13
15
  validate?: (value: unknown, ctx: Context) => string | null | Promise<string | null>;
14
16
  opts?: Record<string, unknown>;
17
+ /** Tombol pilihan: ['A','B'] | [{text,value}] | baris eksplisit [['A'],['B','C']]. */
18
+ buttons?: Array<WizardButton | WizardButton[]>;
19
+ /** true → tombol inline (callback), default reply keyboard. */
20
+ inline?: boolean;
21
+ /** true/string → tolak ketikan bebas, wajib pilih tombol. */
22
+ onlyButtons?: boolean | string;
23
+ /** Override mode tampilan per langkah. */
24
+ mode?: WizardMode;
25
+ /** Reply keyboard sekali pakai. */
26
+ oneTime?: boolean;
15
27
  }
16
28
  interface WizardDef {
17
29
  steps: WizardStep[];
18
30
  done: (answers: Record<string, unknown>, ctx: Context) => unknown;
19
31
  cancelWords?: string[];
20
32
  onCancel?: (ctx: Context) => unknown;
33
+ /** 'send' (default) | 'edit' satu pesan | 'delete' tanya-hapus per langkah. */
34
+ mode?: WizardMode;
35
+ /** Hapus pesan tanya terakhir saat wizard selesai (default: true bila mode 'delete'). */
36
+ cleanup?: boolean;
37
+ /** Singkirkan reply keyboard saat selesai (default: true bila tombol reply pernah dipakai). */
38
+ removeKeyboard?: boolean;
21
39
  }
22
40
 
23
41
  interface TeleBibzOpts {
@@ -25,13 +43,13 @@ declare module '@xbibzlibrary/telebibz' {
25
43
  onError?: (err: unknown, ctx?: Context) => unknown;
26
44
  silent?: boolean;
27
45
  dropPending?: boolean;
28
- grammy?: Record<string, unknown>;
46
+ session?: { initial?: () => unknown; getKey?: (ctx: Context) => string | undefined; storage?: unknown };
47
+ transport?: (method: string, payload?: object) => Promise<unknown>;
29
48
  }
30
49
 
31
50
  class TeleBibz {
32
51
  constructor(token: string, opts?: TeleBibzOpts);
33
- bot: Bot<Context>;
34
- api: Bot<Context>['api'];
52
+ api: any;
35
53
  botInfo: any;
36
54
  use(...mw: any[]): this;
37
55
  cmd(names: string | string[], ...mw: Handler[]): this;
@@ -42,6 +60,9 @@ declare module '@xbibzlibrary/telebibz' {
42
60
  wizard(id: string, def: WizardDef, bindCommand?: boolean): this;
43
61
  wizardStart(ctx: Context, id: string): Promise<unknown>;
44
62
  wizardActive(ctx: Context): boolean;
63
+ wizardCancel(ctx: Context): Promise<boolean>;
64
+ wizardEdit(ctx: Context, text: string, extra?: object): Promise<unknown>;
65
+ wizardDelete(ctx: Context): Promise<boolean>;
45
66
  broadcast(ids: Array<number | string>, pesan: any, opts?: { delay?: number }): Promise<{ terkirim: number; gagal: number; errors: Array<{ chatId: any; pesan: string }> }>;
46
67
  launch(opts?: Record<string, unknown>): Promise<this>;
47
68
  handleUpdate(update: any): Promise<void>;
@@ -63,6 +84,10 @@ declare module '@xbibzlibrary/telebibz' {
63
84
  define: (id: string, def: WizardDef) => string;
64
85
  start: (ctx: Context, id: string) => Promise<unknown>;
65
86
  active: (ctx: Context) => boolean;
87
+ cancel: (ctx: Context) => Promise<boolean>;
88
+ editAsk: (ctx: Context, text: string, extra?: object) => Promise<unknown>;
89
+ deleteAsk: (ctx: Context) => Promise<boolean>;
90
+ KEY: string;
66
91
  };
67
92
 
68
93
  function humanize(err: unknown): { pesan: string; saran: string | null; method?: string; code?: number };
package/index.js CHANGED
@@ -25,6 +25,8 @@ const say = {
25
25
  module.exports = {
26
26
  // kelas utama
27
27
  TeleBibz, Context, Composer, BotError, session,
28
+ // wizard (form percakapan + tombol + edit/delete)
29
+ wizard,
28
30
  // keyboard & menu
29
31
  btn, url, webApp, copy, kb, InlineKeyboard, Keyboard, Menu, MenuContainer,
30
32
  // percakapan
package/lib/telebibz.js CHANGED
@@ -86,6 +86,9 @@ class TeleBibz {
86
86
  }
87
87
  wizardStart(ctx, id) { return wizard.start(ctx, id); }
88
88
  wizardActive(ctx) { return wizard.active(ctx); }
89
+ wizardCancel(ctx) { return wizard.cancel(ctx); }
90
+ wizardEdit(ctx, text, extra) { return wizard.editAsk(ctx, text, extra); }
91
+ wizardDelete(ctx) { return wizard.deleteAsk(ctx); }
89
92
 
90
93
  /* ---------- broadcast ---------- */
91
94
  broadcast(ids, pesan, opts) { return broadcast(this.api, ids, pesan, opts); }
package/lib/wizard.js CHANGED
@@ -1,4 +1,6 @@
1
1
  // lib/wizard.js — percakapan tanya-jawab berurutan (form) berbasis sesi.
2
+ // v3.1: + TOMBOL pilihan (reply keyboard / inline keyboard), mode tampilan
3
+ // 'send' | 'edit' | 'delete', cleanup pesan & hapus keyboard otomatis.
2
4
  'use strict';
3
5
 
4
6
  const KEY = '__telebibz_wizard';
@@ -6,15 +8,28 @@ const KEY = '__telebibz_wizard';
6
8
  /**
7
9
  * Definisi wizard:
8
10
  * {
11
+ * mode: 'send' | 'edit' | 'delete', // ops, default 'send' (per-step bisa override)
12
+ * cleanup: true, // ops: hapus pesan tanya terakhir saat selesai
13
+ * removeKeyboard: true, // ops: singkirkan reply keyboard saat selesai
9
14
  * steps: [
10
15
  * { key:'nama', ask:'Siapa namamu?' },
11
- * { key:'umur', ask:'Umur berapa?', parse: Number,
16
+ * { key:'jk', ask:'Jenis kelamin?', buttons: ['Laki-laki', 'Perempuan'] },
17
+ * { key:'setuju', ask:'Setuju?', inline: true,
18
+ * buttons: [[{ text:'✅ Ya', value:'ya' }, { text:'❌ Tidak', value:'tdk' }]] },
19
+ * { key:'umur', ask:'Umur berapa?', parse: Number, mode: 'edit', onlyButtons: false,
12
20
  * validate: (n) => (n>0 && n<120) ? null : 'Umur tidak masuk akal, ulangi:' },
13
21
  * ],
14
22
  * done: async (answers, ctx) => {},
15
23
  * cancelWords: ['batal', '/batal', 'cancel'], // ops, default ada
16
24
  * onCancel: (ctx) => ctx.reply('Dibatalkan.'), // ops
17
25
  * }
26
+ *
27
+ * Format `buttons`:
28
+ * ['A', 'B'] → label polos, nilai = label
29
+ * [{ text:'A', value:'a' }] → label + nilai berbeda (flat, disusun 2 per baris)
30
+ * [['A','B'], ['C']] → baris eksplisit (reply maupun inline)
31
+ * `inline: true` → tombol callback (klik = nilai langsung, tanpa mengetik)
32
+ * `onlyButtons: true` → tolak ketikan bebas, wajib pilih tombol
18
33
  */
19
34
  const wizards = new Map();
20
35
 
@@ -29,14 +44,149 @@ function define(id, def) {
29
44
 
30
45
  function get(id) { return wizards.get(id); }
31
46
 
47
+ /* ---------- normalisasi tombol ---------- */
48
+ function normalizeButtons(input) {
49
+ if (!Array.isArray(input) || !input.length) return null;
50
+ const norm = (b) => (b && typeof b === 'object' && b.text !== undefined
51
+ ? { text: String(b.text), value: b.value !== undefined ? b.value : String(b.text) }
52
+ : { text: String(b), value: String(b) });
53
+ if (Array.isArray(input[0])) return input.map((r) => (Array.isArray(r) ? r : [r]).map(norm));
54
+ // daftar flat → susun rapi maksimal 2 tombol per baris
55
+ const flat = input.map(norm);
56
+ const rows = [];
57
+ for (let i = 0; i < flat.length; i += 2) rows.push(flat.slice(i, i + 2));
58
+ return rows;
59
+ }
60
+ const flatten = (rows) => rows.reduce((a, r) => a.concat(r), []);
61
+
62
+ /** Susun reply_markup untuk langkah ke-`idx` (jika ada tombolnya). */
63
+ function stepExtra(def, st, idx) {
64
+ const step = def.steps[idx];
65
+ const extra = { ...(step.opts || {}) };
66
+ const rows = normalizeButtons(step.buttons);
67
+ if (!rows) return extra;
68
+ if (step.inline) {
69
+ let n = 0;
70
+ extra.reply_markup = {
71
+ inline_keyboard: rows.map((r) => r.map((b) => ({
72
+ text: b.text,
73
+ callback_data: `wiz:${st.cb}:${idx}:${n++}`,
74
+ }))),
75
+ };
76
+ } else {
77
+ st.rk = true; // pernah memakai reply keyboard → perlu dibersihkan saat selesai
78
+ extra.reply_markup = {
79
+ keyboard: rows.map((r) => r.map((b) => ({ text: b.text }))),
80
+ resize_keyboard: true,
81
+ ...(step.oneTime ? { one_time_keyboard: true } : {}),
82
+ };
83
+ }
84
+ return extra;
85
+ }
86
+
87
+ /** Tampilkan pertanyaan langkah `idx` sesuai mode ('send' | 'edit' | 'delete'). */
88
+ async function tampil(ctx, st, def, idx, text) {
89
+ const step = def.steps[idx];
90
+ const mode = step.mode || def.mode || 'send';
91
+ const extra = stepExtra(def, st, idx);
92
+
93
+ if (mode === 'edit' && st.msgId) {
94
+ try {
95
+ await ctx.api.editMessageText(st.chatId, st.msgId, text, extra);
96
+ return;
97
+ } catch (e) {
98
+ if (/not modified/i.test((e && (e.description || e.message)) || '')) return;
99
+ // pesan tak ditemukan (sudah dihapus) → jatuh ke kirim baru
100
+ }
101
+ } else if (mode === 'delete' && st.msgId) {
102
+ try { await ctx.api.deleteMessage(st.chatId, st.msgId); } catch { /* sudah hilang */ }
103
+ st.msgId = undefined;
104
+ }
105
+ const sent = await ctx.reply(text, extra);
106
+ if (sent && sent.message_id) { st.chatId = ctx.chatId; st.msgId = sent.message_id; }
107
+ }
108
+
109
+ /** Bersihkan jejak wizard: hapus pesan tanya (cleanup) + singkirkan reply keyboard. */
110
+ async function bersih(ctx, st, def) {
111
+ if (!def) return;
112
+ const cleanupOn = def.cleanup !== undefined ? !!def.cleanup : (def.mode === 'delete');
113
+ if (cleanupOn && st.msgId) {
114
+ try { await ctx.api.deleteMessage(st.chatId, st.msgId); } catch { /* sudah hilang */ }
115
+ }
116
+ if (st.rk && def.removeKeyboard !== false) {
117
+ try {
118
+ await ctx.api.sendMessage(st.chatId, '\u200b', { reply_markup: { remove_keyboard: true } });
119
+ } catch { /* chat tak terjangkau */ }
120
+ }
121
+ }
122
+
32
123
  /** Mulai wizard dari handler mana pun (command/action/dll). */
33
124
  async function start(ctx, id) {
34
125
  const def = wizards.get(id);
35
126
  if (!def) throw new Error(`wizard "${id}" belum didefinisikan`);
36
127
  if (!ctx.session) throw new Error('telebibz: session belum aktif (lib internal)');
37
- ctx.session[KEY] = { id, i: 0, ans: {} };
128
+ const st = { id, i: 0, ans: {}, cb: Math.random().toString(36).slice(2, 8) };
129
+ ctx.session[KEY] = st;
38
130
  const s0 = def.steps[0];
39
- return ctx.reply(typeof s0.ask === 'function' ? await s0.ask(ctx) : s0.ask, s0.opts || {});
131
+ const text = typeof s0.ask === 'function' ? await s0.ask(ctx) : s0.ask;
132
+ return tampil(ctx, st, def, 0, text);
133
+ }
134
+
135
+ /** Proses satu jawaban (teks ketikan ATAU nilai tombol) untuk langkah aktif. */
136
+ async function jawab(ctx, st, def, input) {
137
+ const step = def.steps[st.i];
138
+ let value = input;
139
+ if (step.parse) value = await step.parse(input, ctx);
140
+ if (step.validate) {
141
+ const errMsg = await step.validate(value, ctx);
142
+ if (errMsg) {
143
+ const mode = step.mode || def.mode || 'send';
144
+ if (mode === 'send') return ctx.reply(errMsg);
145
+ return tampil(ctx, st, def, st.i, errMsg); // edit/delete: ubah pesan yang sama
146
+ }
147
+ }
148
+ st.ans[step.key] = value;
149
+ st.i += 1;
150
+
151
+ if (st.i >= def.steps.length) {
152
+ await bersih(ctx, st, def);
153
+ delete ctx.session[KEY];
154
+ return def.done(st.ans, ctx);
155
+ }
156
+ const ns = def.steps[st.i];
157
+ return tampil(ctx, st, def, st.i, typeof ns.ask === 'function' ? await ns.ask(ctx) : ns.ask);
158
+ }
159
+
160
+ /** Batalkan wizard yang sedang berjalan secara programatis. */
161
+ async function cancel(ctx) {
162
+ const st = ctx.session && ctx.session[KEY];
163
+ if (!st) return false;
164
+ const def = wizards.get(st.id);
165
+ await bersih(ctx, st, def);
166
+ delete ctx.session[KEY];
167
+ if (def && def.onCancel) { await def.onCancel(ctx); return true; }
168
+ await ctx.reply('✖️ Sesi dibatalkan.');
169
+ return true;
170
+ }
171
+
172
+ /** Edit pesan-tanya wizard yang sedang tampil (fallback: kirim baru). */
173
+ async function editAsk(ctx, text, extra = {}) {
174
+ const st = ctx.session && ctx.session[KEY];
175
+ if (!st || !st.msgId) return ctx.reply(text, extra);
176
+ try { await ctx.api.editMessageText(st.chatId, st.msgId, text, extra); }
177
+ catch {
178
+ const sent = await ctx.reply(text, extra);
179
+ if (sent && sent.message_id) { st.chatId = ctx.chatId; st.msgId = sent.message_id; }
180
+ }
181
+ }
182
+
183
+ /** Hapus pesan-tanya wizard yang sedang tampil. */
184
+ async function deleteAsk(ctx) {
185
+ const st = ctx.session && ctx.session[KEY];
186
+ if (!st || !st.msgId) return false;
187
+ try { await ctx.api.deleteMessage(st.chatId, st.msgId); } catch { /* sudah hilang */ }
188
+ st.msgId = undefined;
189
+ return true;
40
190
  }
41
191
 
42
192
  /** Middleware global: memproses jawaban wizard yang sedang berjalan. */
@@ -47,35 +197,51 @@ function middleware() {
47
197
  const def = wizards.get(st.id);
48
198
  if (!def) { delete ctx.session[KEY]; return next(); }
49
199
 
200
+ /* ---- klik tombol inline (callback_query) ---- */
201
+ const q = ctx.callback_query;
202
+ if (q && typeof q.data === 'string') {
203
+ const m = /^wiz:([a-z0-9]+):(\d+):(\d+)$/i.exec(q.data);
204
+ if (m) {
205
+ const stale = (pesan) => ctx.answerCallbackQuery({ text: pesan, show_alert: true }).catch(() => {});
206
+ if (m[1] !== st.cb || Number(m[2]) !== st.i) return stale('⌛ Tombol usang — formulir sudah berpindah/berakhir.');
207
+ const rows = normalizeButtons(def.steps[st.i].buttons);
208
+ const tombol = rows && flatten(rows)[Number(m[3])];
209
+ if (!tombol) return stale('⌛ Tombol usang — formulir sudah berpindah/berakhir.');
210
+ await ctx.answerCallbackQuery().catch(() => {});
211
+ return jawab(ctx, st, def, tombol.value);
212
+ }
213
+ // callback lain saat wizard aktif → diamkan, wizard menunggu (konsisten non-teks)
214
+ }
215
+
216
+ /* ---- jawaban teks ---- */
50
217
  const text = ctx.message && typeof ctx.message.text === 'string' ? ctx.message.text.trim() : null;
51
218
  if (text === null) return; // pesan non-teks (foto/stiker) — diamkan, wizard menunggu
52
219
 
53
220
  if (def.cancelWords.has(text.toLowerCase())) {
221
+ await bersih(ctx, st, def);
54
222
  delete ctx.session[KEY];
55
223
  if (def.onCancel) return def.onCancel(ctx);
56
224
  return ctx.reply('✖️ Sesi dibatalkan.');
57
225
  }
58
226
 
59
227
  const step = def.steps[st.i];
60
- let value = text;
61
- if (step.parse) value = await step.parse(text, ctx);
62
- if (step.validate) {
63
- const errMsg = await step.validate(value, ctx);
64
- if (errMsg) return ctx.reply(errMsg);
65
- }
66
- st.ans[step.key] = value;
67
- st.i += 1;
68
-
69
- if (st.i >= def.steps.length) {
70
- delete ctx.session[KEY];
71
- return def.done(st.ans, ctx);
228
+ let input = text;
229
+ const rows = normalizeButtons(step.buttons);
230
+ if (rows) {
231
+ const hit = flatten(rows).find((b) => b.text.toLowerCase() === text.toLowerCase());
232
+ if (hit) input = hit.value; // tekan tombol reply → pakai nilainya
233
+ else if (step.onlyButtons) {
234
+ const pesan = step.onlyButtons === true
235
+ ? '✋ Pilih salah satu tombol yang tersedia ya:'
236
+ : step.onlyButtons;
237
+ return tampil(ctx, st, def, st.i, pesan);
238
+ }
72
239
  }
73
- const ns = def.steps[st.i];
74
- return ctx.reply(typeof ns.ask === 'function' ? await ns.ask(ctx) : ns.ask, ns.opts || {});
240
+ return jawab(ctx, st, def, input);
75
241
  };
76
242
  }
77
243
 
78
244
  /** True kalau user ini sedang di tengah wizard. */
79
245
  function active(ctx) { return Boolean(ctx.session && ctx.session[KEY]); }
80
246
 
81
- module.exports = { define, get, start, middleware, active, KEY };
247
+ module.exports = { define, get, start, middleware, active, cancel, editAsk, deleteAsk, KEY };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xbibzlibrary/telebibz",
3
- "version": "3.0.1",
4
- "description": "Library Telegram Indonesia production-grade — set fitur penuh setara grammY: proxy-api segala metode, transformer, menus, inline query, media group, throttler & auto-retry, session swappable, webhook, polling tahan-409. //—Xbibz Official—//",
3
+ "version": "3.1.1",
4
+ "description": "Library Telegram Indonesia production-grade — set fitur penuh setara grammY: proxy-api segala metode, transformer, menus, inline query, media group, throttler & auto-retry, wizard + tombol + edit/delete, session swappable, webhook, polling tahan-409. //—Xbibz Official—//",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
7
7
  "type": "commonjs",
package/test/all.test.js CHANGED
@@ -6,7 +6,7 @@ const { TeleBibz, btn, url, kb, webApp, copy, humanize, File, InlineKeyboard,
6
6
  Menu, MenuContainer, iq, autoRetry, throttler, limiter, InputMediaBuilder } = require('..');
7
7
 
8
8
  let N = 0, OK = 0;
9
- const TOTAL = 24;
9
+ const TOTAL = 30;
10
10
  const t = (nama, fn) => {
11
11
  const done = () => { N++; if (N === TOTAL) { console.log(`\n${OK}/${N} lulus`); process.exit(OK === N ? 0 : 1); } };
12
12
  Promise.resolve()
@@ -332,3 +332,108 @@ t('webhook(): body JSON diproses handleUpdate', async () => {
332
332
  assert.strictEqual(status, 200);
333
333
  assert.ok(texts(calls).some((x) => /hai webhook/.test(x)));
334
334
  });
335
+ /* ===== 25–30. wizard v3.1: tombol + edit/delete ===== */
336
+ t('wizard v3.1: tombol reply — label cocok → nilai; onlyButtons menahan ketikan bebas', async () => {
337
+ const { bot, calls } = buatBot(); await boot(bot);
338
+ const jawab = [];
339
+ bot.wizard('survey', {
340
+ steps: [
341
+ { key: 'jk', ask: 'Jenis kelamin?', buttons: ['Laki-laki', 'Perempuan'], onlyButtons: true },
342
+ { key: 'kota', ask: 'Kota?' },
343
+ ],
344
+ done: async (ans) => { jawab.push(ans); },
345
+ });
346
+ await bot.handleUpdate(uMsg('/survey'));
347
+ const ask = calls.find((c) => c.method === 'sendMessage' && c.payload.text === 'Jenis kelamin?');
348
+ assert.ok(ask && ask.payload.reply_markup.keyboard[0][0].text === 'Laki-laki');
349
+ await bot.handleUpdate(uMsg('terserah')); // bukan tombol → ditolak, wizard tetap di langkah 1
350
+ assert.strictEqual(jawab.length, 0);
351
+ await bot.handleUpdate(uMsg('Perempuan')); // ketuk tombol
352
+ await bot.handleUpdate(uMsg('Magetan'));
353
+ assert.deepStrictEqual(jawab, [{ jk: 'Perempuan', kota: 'Magetan' }]);
354
+ });
355
+
356
+ t('wizard v3.1: tombol inline + mode edit → editMessageText, bukan pesan baru', async () => {
357
+ const { bot, calls } = buatBot(); await boot(bot);
358
+ const jawab = [];
359
+ bot.wizard('vote', {
360
+ mode: 'edit',
361
+ steps: [
362
+ { key: 'pilih', ask: 'Pilih salah satu', inline: true, buttons: [[{ text: 'Opsi A', value: 'a' }, { text: 'Opsi B', value: 'b' }]] },
363
+ { key: 'nama', ask: 'Siapa namamu?' },
364
+ ],
365
+ done: async (ans) => { jawab.push(ans); },
366
+ });
367
+ await bot.handleUpdate(uMsg('/vote'));
368
+ const ask = calls.find((c) => c.method === 'sendMessage' && c.payload.text === 'Pilih salah satu');
369
+ const data = ask.payload.reply_markup.inline_keyboard[0][1].callback_data;
370
+ assert.ok(/^wiz:[a-z0-9]+:0:1$/.test(data));
371
+ await bot.handleUpdate({ update_id: ++uid, callback_query: {
372
+ id: 'wq1', from: USER, chat_instance: 'x', data,
373
+ message: { message_id: ask.message_id, from: { id: 99, is_bot: true, first_name: 'B' }, chat: CHAT, date: 1 },
374
+ } });
375
+ assert.ok(calls.some((c) => c.method === 'editMessageText' && c.payload.text === 'Siapa namamu?'), 'langkah 2 harus lewat editMessageText');
376
+ assert.ok(!calls.some((c) => c.method === 'sendMessage' && c.payload.text === 'Siapa namamu?'), 'tidak boleh kirim pesan baru');
377
+ assert.ok(calls.some((c) => c.method === 'answerCallbackQuery' && c.payload.callback_query_id === 'wq1'));
378
+ await bot.handleUpdate(uMsg('Budi'));
379
+ assert.deepStrictEqual(jawab, [{ pilih: 'b', nama: 'Budi' }]);
380
+ });
381
+
382
+ t('wizard v3.1: mode delete → pesan lama dihapus; cleanup hapus pesan tanya terakhir', async () => {
383
+ const { bot, calls } = buatBot(); await boot(bot);
384
+ bot.wizard('hapus', {
385
+ mode: 'delete',
386
+ steps: [{ key: 'a', ask: 'Pertanyaan A?' }, { key: 'b', ask: 'Pertanyaan B?' }],
387
+ done: () => {},
388
+ });
389
+ await bot.handleUpdate(uMsg('/hapus'));
390
+ const idxA = calls.findIndex((c) => c.method === 'sendMessage' && c.payload.text === 'Pertanyaan A?');
391
+ assert.ok(idxA >= 0);
392
+ const idA = idxA + 1; // stub transport: message_id = calls.length saat push
393
+ await bot.handleUpdate(uMsg('satu'));
394
+ const dihapus = () => calls.filter((c) => c.method === 'deleteMessage').map((c) => c.payload.message_id);
395
+ assert.ok(dihapus().includes(idA), 'pesan A harus dihapus sebelum tanya B');
396
+ const idxB = calls.findIndex((c) => c.method === 'sendMessage' && c.payload.text === 'Pertanyaan B?');
397
+ await bot.handleUpdate(uMsg('dua'));
398
+ assert.ok(dihapus().includes(idxB + 1), 'cleanup: pesan tanya terakhir ikut dihapus');
399
+ });
400
+
401
+ t('wizard v3.1: reply keyboard otomatis disingkirkan saat wizard selesai', async () => {
402
+ const { bot, calls } = buatBot(); await boot(bot);
403
+ bot.wizard('kbd', { steps: [{ key: 'x', ask: 'Pilih:', buttons: ['Ok', 'Tidak'] }], done: () => {} });
404
+ await bot.handleUpdate(uMsg('/kbd'));
405
+ await bot.handleUpdate(uMsg('Ok'));
406
+ assert.ok(calls.some((c) => c.method === 'sendMessage' &&
407
+ c.payload.reply_markup && c.payload.reply_markup.remove_keyboard === true));
408
+ });
409
+
410
+ t('wizard v3.1: klik tombol usang → alert aman, wizard tidak rusak', async () => {
411
+ const { bot, calls } = buatBot(); await boot(bot);
412
+ bot.wizard('prog', { steps: [{ key: 'a', ask: 'A?', inline: true, buttons: ['X'] }], done: () => {} });
413
+ await bot.handleUpdate(uMsg('/prog'));
414
+ await bot.handleUpdate({ update_id: ++uid, callback_query: {
415
+ id: 'wq2', from: USER, chat_instance: 'x', data: 'wiz:tokenpalsu:0:0',
416
+ message: { message_id: 1, from: { id: 99, is_bot: true, first_name: 'B' }, chat: CHAT, date: 1 },
417
+ } });
418
+ const alert = calls.find((c) => c.method === 'answerCallbackQuery' && /usang/i.test(c.payload.text || ''));
419
+ assert.ok(alert && alert.payload.show_alert === true);
420
+ await bot.handleUpdate(uMsg('jawaban asli')); // wizard masih berjalan normal
421
+ });
422
+
423
+ t('wizard v3.1: wizard.cancel() membatalkan sesi secara programatis', async () => {
424
+ const wiz = require('../lib/wizard');
425
+ wiz.define('manual', { steps: [{ key: 'a', ask: 'A?' }], done: () => {}, onCancel: () => {} });
426
+ const replies = [];
427
+ const ctx = {
428
+ session: {},
429
+ chatId: 555,
430
+ reply: async (t) => { replies.push(t); return { message_id: 42 }; },
431
+ api: { editMessageText: async () => true, deleteMessage: async () => true, sendMessage: async () => true },
432
+ };
433
+ await wiz.start(ctx, 'manual');
434
+ assert.strictEqual(wiz.active(ctx), true);
435
+ assert.deepStrictEqual(replies, ['A?']);
436
+ assert.strictEqual(await wiz.cancel(ctx), true);
437
+ assert.strictEqual(wiz.active(ctx), false);
438
+ assert.strictEqual(await wiz.cancel(ctx), false); // sudah tidak aktif
439
+ });