Skip to content

Commit c5db075

Browse files
committed
feat(builder): inherit the chat disappearing timer on every send
1 parent 0660c6d commit c5db075

6 files changed

Lines changed: 93 additions & 6 deletions

File tree

docs/content/message-payload.mdx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -300,9 +300,10 @@ 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-
- **`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"*.
303+
- **Disappearing timers are inherited automatically.** zaileys learns each chat's timer from its
304+
inbound messages, so `ctx.reply()` **and** every `client.send(...)` into that chat — text, media,
305+
stickers, albums — carry it without you asking. Pass `.disappearing(seconds)` only to override it.
306+
Messages sent without the timer are the ones WhatsApp flags *"this message won't disappear"*.
306307
- **`senderUsername` is usually `null` — WhatsApp sends the phone number _or_ the username, never
307308
both.** A username is the fallback handle for a sender whose number you cannot see, so it stays
308309
`null` for anyone whose phone number your account already knows (which is most people). It arrives

docs/content/sending-messages.mdx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,15 @@ Send as an ephemeral/disappearing message. Takes a **positive integer** number o
647647
await client.send(to).text('self-destructs in a day').disappearing(86400)
648648
```
649649

650+
<Callout type="info">
651+
**You rarely need this.** zaileys learns each chat's disappearing timer from the messages it
652+
receives, and every send into that chat inherits it automatically — replies, media, stickers,
653+
albums alike. Call `.disappearing()` only to override the chat's own timer.
654+
655+
A message sent *without* the chat's timer is what makes WhatsApp show
656+
*"this message won't disappear"* beside it.
657+
</Callout>
658+
650659
### `.to(recipient)`
651660

652661
Reassign the recipient on an `init`-state builder before adding content (rarely needed since

src/builder/builder.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,10 +119,11 @@ export class MessageBuilder<State extends BuilderState> {
119119
recipient: string,
120120
resolveRecipient?: (raw: string) => Promise<string>,
121121
recordSent?: (message: WAMessage) => void,
122+
inheritDisappearing?: (jid: string) => number | undefined,
122123
): MessageBuilder<'init'> {
123124
return new MessageBuilder<'init'>(
124125
socket,
125-
createInternalState(recipient, resolveRecipient, recordSent),
126+
createInternalState(recipient, resolveRecipient, recordSent, inheritDisappearing),
126127
)
127128
}
128129

@@ -349,6 +350,11 @@ export class MessageBuilder<State extends BuilderState> {
349350
this.internal.recipient = await this.internal.resolveRecipient(this.internal.recipient)
350351
delete this.internal.resolveRecipient
351352
}
353+
/** Resolved once here so text, media, album and relay sends all inherit the chat's timer. */
354+
if (this.internal.disappearingSeconds === undefined) {
355+
const inherited = this.internal.inheritDisappearing?.(this.internal.recipient)
356+
if (inherited !== undefined && inherited > 0) this.internal.disappearingSeconds = inherited
357+
}
352358
if (this.internal.statusJidList !== undefined && this.internal.recipient !== STATUS_BROADCAST_JID) {
353359
throw new ZaileysBuilderError(
354360
'INVALID_RECIPIENT',

src/builder/state.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,18 @@ export type BuilderInternalState = {
1313
disappearingSeconds?: number
1414
resolveRecipient?: (raw: string) => Promise<string>
1515
recordSent?: (message: WAMessage) => void
16+
/** The chat's own disappearing timer, applied when the caller did not set one. */
17+
inheritDisappearing?: (jid: string) => number | undefined
1618
}
1719

1820
export const createInternalState = (
1921
recipient: string,
2022
resolveRecipient?: (raw: string) => Promise<string>,
2123
recordSent?: (message: WAMessage) => void,
24+
inheritDisappearing?: (jid: string) => number | undefined,
2225
): BuilderInternalState => ({
2326
recipient,
2427
...(resolveRecipient ? { resolveRecipient } : {}),
2528
...(recordSent ? { recordSent } : {}),
29+
...(inheritDisappearing ? { inheritDisappearing } : {}),
2630
})

src/client/client.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,8 @@ export class Client extends TypedEventEmitter<ClientEventMap> {
210210
private readonly presenceThrottle: PresenceThrottleOptions | undefined
211211
private readonly scheduleLimiter: RateLimiter | undefined
212212
private authExhausted = false
213+
/** Each chat's disappearing timer, learned from inbound messages so outbound sends can inherit it. */
214+
private readonly chatExpiration = new Map<string, number>()
213215
private _socket: BaileysSocket | undefined
214216
private reconnectTimer: ReturnType<typeof setTimeout> | undefined
215217
private listenerCleanup: SocketCleanup[] = []
@@ -269,6 +271,7 @@ export class Client extends TypedEventEmitter<ClientEventMap> {
269271
this.qrTerminal = options.qrTerminal ?? true
270272
this.statusLog = options.statusLog ?? true
271273
if (this.statusLog) suppressLibsignalNoise()
274+
this.on('message', (msg) => this.rememberChatExpiration(msg))
272275
this.reconnectOptions = options.reconnect ?? {}
273276
this.baileysExtra = options.baileys ?? {}
274277
this.auth = options.auth ?? new FileAuthStore({ basePath: `./.zaileys/auth/${this.sessionId}` })
@@ -873,10 +876,31 @@ export class Client extends TypedEventEmitter<ClientEventMap> {
873876
const recordSent = (message: WAMessage): void => {
874877
void this.store.saveMessage(message).catch((err) => this.logger.warn(err, 'recordSent failed'))
875878
}
879+
const inherit = (jid: string): number | undefined => this.chatExpiration.get(jid)
876880
if (this._provider === 'cloud' || isJid(to)) {
877-
return MessageBuilder.create(socket, to, undefined, recordSent)
881+
return MessageBuilder.create(socket, to, undefined, recordSent, inherit)
882+
}
883+
return MessageBuilder.create(
884+
socket,
885+
to,
886+
(raw) => this.resolveRecipient(raw),
887+
recordSent,
888+
inherit,
889+
)
890+
}
891+
892+
private rememberChatExpiration(msg: MessageContext): void {
893+
const room = msg.roomId
894+
if (room === null) return
895+
if (msg.ephemeralDuration === null) {
896+
this.chatExpiration.delete(room)
897+
return
898+
}
899+
if (this.chatExpiration.size >= 500 && !this.chatExpiration.has(room)) {
900+
const oldest = this.chatExpiration.keys().next().value
901+
if (oldest !== undefined) this.chatExpiration.delete(oldest)
878902
}
879-
return MessageBuilder.create(socket, to, (raw) => this.resolveRecipient(raw), recordSent)
903+
this.chatExpiration.set(room, msg.ephemeralDuration)
880904
}
881905

882906
edit(key: WAMessageKey): EditBuilder {

tests/client/reply-lifetime.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const msg = (expiration?: number): MessageContext =>
2424
senderId: SENDER,
2525
roomId: SENDER,
2626
isGroup: false,
27+
ephemeralDuration: expiration ?? null,
2728
message: () => quoted(expiration),
2829
}) as unknown as MessageContext
2930

@@ -68,3 +69,45 @@ describe('replies inherit the disappearing timer', () => {
6869
expect(optionsOf(sendMessage).ephemeralExpiration).toBeUndefined()
6970
})
7071
})
72+
73+
describe('every send into a disappearing chat inherits its timer', () => {
74+
/** Teaches the client the chat's timer the way a real inbound message would. */
75+
const learn = (client: Client, seconds: number): void => {
76+
client.emit('message', msg(seconds))
77+
}
78+
79+
it('applies it to a plain text send, not just a reply', async () => {
80+
const { client, sendMessage } = connected()
81+
learn(client, 86400)
82+
await client.send(SENDER).text('halo')
83+
expect(optionsOf(sendMessage).ephemeralExpiration).toBe(86400)
84+
})
85+
86+
it('applies it to media — the sticker case', async () => {
87+
const { client, sendMessage } = connected()
88+
learn(client, 604800)
89+
await client.send(SENDER).image(Buffer.from('jpeg'))
90+
expect(optionsOf(sendMessage).ephemeralExpiration).toBe(604800)
91+
})
92+
93+
it('never overrides a timer the caller set explicitly', async () => {
94+
const { client, sendMessage } = connected()
95+
learn(client, 604800)
96+
await client.send(SENDER).text('halo').disappearing(60)
97+
expect(optionsOf(sendMessage).ephemeralExpiration).toBe(60)
98+
})
99+
100+
it('adds nothing for a chat it has never seen', async () => {
101+
const { client, sendMessage } = connected()
102+
await client.send('628999@s.whatsapp.net').text('halo')
103+
expect(optionsOf(sendMessage).ephemeralExpiration).toBeUndefined()
104+
})
105+
106+
it('forgets the timer once the chat stops disappearing', async () => {
107+
const { client, sendMessage } = connected()
108+
learn(client, 604800)
109+
client.emit('message', msg())
110+
await client.send(SENDER).text('halo')
111+
expect(optionsOf(sendMessage).ephemeralExpiration).toBeUndefined()
112+
})
113+
})

0 commit comments

Comments
 (0)