Skip to content

Commit 6b0ac12

Browse files
committed
fix(commands): inherit the chat disappearing timer when replying
1 parent 3566a2e commit 6b0ac12

4 files changed

Lines changed: 97 additions & 5 deletions

File tree

docs/content/message-payload.mdx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -300,8 +300,9 @@ A few values come from your `Client` config rather than the raw message:
300300
- **`isSpam` is reserved** and always `false` today. Use `forwardCount >= 5` for chain-message detection.
301301
- **Answer only live messages.** After a reconnect WhatsApp replays everything you missed. Guard with
302302
`if (ctx.isOld) return` unless you deliberately want to process the backlog.
303-
- **Mirror the disappearing timer on replies.** `ctx.reply()` does not inherit it — pass
304-
`ctx.ephemeralDuration` to `.disappearing()` yourself, or your answer outlives the thread it belongs to.
303+
- **`ctx.reply()` inherits the disappearing timer automatically.** An answer in a disappearing chat
304+
disappears with it. Only `client.send(...)` needs `.disappearing(ctx.ephemeralDuration)` passed by
305+
hand — without it WhatsApp marks the message *"this message won't disappear"*.
305306
- **`senderUsername` is usually `null` — WhatsApp sends the phone number _or_ the username, never
306307
both.** A username is the fallback handle for a sender whose number you cannot see, so it stays
307308
`null` for anyone whose phone number your account already knows (which is most people). It arrives

src/client/client.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import {
7373
} from '../automation/index.js'
7474
import type { CitationConfig, MessageContext } from '../events/context.js'
7575
import { createDownloadFn } from '../events/decoders/_media-download.js'
76+
import { ephemeralExpirationOf } from '../events/decoders/messages.js'
7677
import type { CallPayload, MediaDownloadResult, MediaKind } from '../events/types.js'
7778
import {
7879
formatConnectionStatus,
@@ -614,7 +615,7 @@ export class Client extends TypedEventEmitter<ClientEventMap> {
614615
resolveQuoted: (id, remoteJid) => this.lookupQuoted(id, remoteJid),
615616
resolveLidToPn: () => Promise.resolve(null),
616617
sendReply: async (target, content, opts, quoted) =>
617-
await this.send(target).text(content, opts).reply(quoted),
618+
await this.replyWithSameLifetime(target, content, opts, quoted),
618619
react: (key, emoji) => this.react(key, emoji),
619620
ignoreMe: this.ignoreMe,
620621
})
@@ -805,6 +806,19 @@ export class Client extends TypedEventEmitter<ClientEventMap> {
805806
})
806807
}
807808

809+
/** Replies inherit the quoted message's disappearing timer, so an answer never outlives its thread. */
810+
private async replyWithSameLifetime(
811+
target: string,
812+
content: string,
813+
opts: TextOptions | undefined,
814+
quoted: WAMessage,
815+
): Promise<WAMessageKey> {
816+
const builder = this.send(target).text(content, opts)
817+
const expiration = ephemeralExpirationOf(quoted)
818+
const withLifetime = expiration !== undefined ? builder.disappearing(expiration) : builder
819+
return withLifetime.reply(quoted)
820+
}
821+
808822
/** Admin lookup for the `admin` guard. Never throws — an unreachable group means "not an admin". */
809823
private async isGroupAdmin(groupJid: string, senderJid: string): Promise<boolean> {
810824
try {
@@ -835,7 +849,7 @@ export class Client extends TypedEventEmitter<ClientEventMap> {
835849
json: resolved.json,
836850
reply: async (content: string, opts?: TextOptions): Promise<WAMessageKey> => {
837851
const target = msg.message().key.remoteJid ?? msg.roomId ?? msg.senderId
838-
const key = await this.send(target).text(content, opts).reply(msg.message())
852+
const key = await this.replyWithSameLifetime(target, content, opts, msg.message())
839853
lastSentKey = key
840854
return key
841855
},
@@ -1141,7 +1155,7 @@ export class Client extends TypedEventEmitter<ClientEventMap> {
11411155
resolveQuoted: (id, remoteJid) => this.lookupQuoted(id, remoteJid),
11421156
resolveLidToPn: (lid) => this.lidToPn(lid),
11431157
sendReply: async (target, content, opts, quoted) =>
1144-
await this.send(target).text(content, opts).reply(quoted),
1158+
await this.replyWithSameLifetime(target, content, opts, quoted),
11451159
react: (key, emoji) => this.react(key, emoji),
11461160
ignoreMe: this.ignoreMe,
11471161
})

src/events/decoders/messages.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,13 @@ const isEphemeralOf = (msg: WAMessage, contextInfo: WAContextInfo | null): boole
516516
return typeof contextInfo?.expiration === 'number' && contextInfo.expiration > 0
517517
}
518518

519+
/**
520+
* The disappearing timer a reply must copy. Without it WhatsApp flags the answer as one that will
521+
* not disappear, leaving it behind in a chat whose other messages are gone.
522+
*/
523+
export const ephemeralExpirationOf = (msg: WAMessage): number | undefined =>
524+
ephemeralDurationOf(msg, contextInfoOf(msg)) ?? undefined
525+
519526
const numOr = (value: unknown, fallback: number): number =>
520527
typeof value === 'number' && Number.isFinite(value) ? value : fallback
521528

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
import type { MiscMessageGenerationOptions, WAMessage } from 'baileys'
3+
import { Client } from '../../src/client/client.js'
4+
import { MemoryAuthStore } from '../../src/auth/adapters/memory.js'
5+
import type { MessageContext } from '../../src/events/context.js'
6+
7+
const SENDER = '628111@s.whatsapp.net'
8+
9+
/** A quoted message from a chat whose disappearing timer is `expiration` seconds. */
10+
const quoted = (expiration?: number): WAMessage =>
11+
({
12+
key: { remoteJid: SENDER, id: 'M1', fromMe: false },
13+
message: {
14+
extendedTextMessage: {
15+
text: 'halo',
16+
...(expiration === undefined ? {} : { contextInfo: { expiration } }),
17+
},
18+
},
19+
}) as unknown as WAMessage
20+
21+
const msg = (expiration?: number): MessageContext =>
22+
({
23+
text: '!ping',
24+
senderId: SENDER,
25+
roomId: SENDER,
26+
isGroup: false,
27+
message: () => quoted(expiration),
28+
}) as unknown as MessageContext
29+
30+
const connected = () => {
31+
const sendMessage = vi.fn(async () => ({ key: { remoteJid: SENDER, id: 'OUT', fromMe: true } }))
32+
const client = new Client({
33+
auth: new MemoryAuthStore(),
34+
qrTerminal: false,
35+
autoConnect: false,
36+
commandPrefix: '!',
37+
})
38+
;(client as unknown as { _socket: unknown })._socket = {
39+
user: { id: 'me@s.whatsapp.net' },
40+
sendMessage,
41+
}
42+
return { client, sendMessage }
43+
}
44+
45+
const optionsOf = (
46+
sendMessage: ReturnType<typeof connected>['sendMessage'],
47+
): MiscMessageGenerationOptions =>
48+
(sendMessage.mock.calls[0] as unknown as [string, unknown, MiscMessageGenerationOptions])[2]
49+
50+
describe('replies inherit the disappearing timer', () => {
51+
it('copies the expiration of the message it answers', async () => {
52+
const { client, sendMessage } = connected()
53+
client.command('ping', async (ctx) => {
54+
await ctx.reply('pong')
55+
})
56+
client.emit('text', msg(604800))
57+
await new Promise((r) => setTimeout(r, 20))
58+
expect(optionsOf(sendMessage).ephemeralExpiration).toBe(604800)
59+
})
60+
61+
it('sends no expiration when the chat keeps its messages', async () => {
62+
const { client, sendMessage } = connected()
63+
client.command('ping', async (ctx) => {
64+
await ctx.reply('pong')
65+
})
66+
client.emit('text', msg())
67+
await new Promise((r) => setTimeout(r, 20))
68+
expect(optionsOf(sendMessage).ephemeralExpiration).toBeUndefined()
69+
})
70+
})

0 commit comments

Comments
 (0)