Skip to content

Commit 4e170a9

Browse files
committed
fix(chat-flow): serialize per-chat processing, end flow on dead-leaf nodes (v1.0.3)
- processMessage now serializes per (session, chat) through a self-evicting promise chain, closing a read->write race where two near-simultaneous messages could lose or duplicate navigation (double greeting, resurrected leaf). The bounded invalid-path re-process recurses on the locked body to avoid self-deadlock. - When a config edit parks an in-flight user on a node that no longer has options, the flow ends cleanly instead of looping "Invalid option" until the 15-minute expiry.
1 parent f5e85e5 commit 4e170a9

7 files changed

Lines changed: 74 additions & 7 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ This repository provides:
3535
| Plugin | Description | Version | Status |
3636
| ------ | ----------- | ------- | ------ |
3737
| [`after-hours`](./after-hours) | Auto-replies with a configurable away/closing message to messages received outside business hours. | 0.1.1 | stable |
38-
| [`chat-flow`](./chat-flow) | Interactive, stateful auto-reply: a trigger word starts a greeting + numbered menu, replies traverse a configurable menu tree, and per-chat state expires after 15 minutes. | 1.0.2 | stable |
38+
| [`chat-flow`](./chat-flow) | Interactive, stateful auto-reply: a trigger word starts a greeting + numbered menu, replies traverse a configurable menu tree, and per-chat state expires after 15 minutes. | 1.0.3 | stable |
3939
| [`faq-bot`](./faq-bot) | Auto-replies to inbound WhatsApp messages from configurable FAQ keyword/regex rules. | 0.1.1 | stable |
4040
| [`group-translate`](./group-translate) | Auto-translates group messages between participants' languages via a LibreTranslate backend. Configure in-chat with /tr commands. Admin-gated; disabled until enabled. | 1.0.2 | stable |
4141
| [`gsheets-logger`](./gsheets-logger) | Logs WhatsApp message events to a Google Sheet via a service account. | 0.2.1 | stable |

chat-flow/CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,17 @@ The version here always matches `manifest.json`'s `version`.
88

99
## [Unreleased]
1010

11+
## [1.0.3] — 2026-06-23
12+
13+
### Fixed
14+
15+
- Messages for the same chat are now processed one at a time (per-session/chat lock), closing a race
16+
where two near-simultaneous messages could read the same flow state and produce lost or duplicated
17+
navigation (e.g. a double greeting or a resurrected leaf). The bounded invalid-path re-process runs
18+
inside the lock to avoid self-deadlock, and the lock map self-evicts when a chat's queue drains.
19+
- If a config edit leaves an in-flight user parked on a node that no longer has options, the flow now
20+
ends cleanly instead of replying "Invalid option" on every message until the 15-minute expiry.
21+
1122
## [1.0.2] — 2026-06-23
1223

1324
### Added

chat-flow/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
| Field | Value |
1414
| ----- | ----- |
1515
| **Identifier** | `chat-flow` |
16-
| **Version** | 1.0.2 |
16+
| **Version** | 1.0.3 |
1717
| **Released** | 2026-06-23 |
1818
| **Status** | stable |
1919
| **Author** | Yudhi Armyndharis |

chat-flow/flow-engine.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,28 @@ test('invalid stored path with a non-trigger input resets and stops (bounded)',
132132
assert.equal(storage.has(key), false);
133133
});
134134

135+
test('concurrent messages for the same chat are serialized (greeting sent once)', async () => {
136+
const { ctx, replies } = makeCtx();
137+
// Two messages racing on a fresh chat: without serialization both read "no state" and both greet.
138+
await Promise.all([
139+
FlowEngine.processMessage(ctx, xyz, 'xyz', 'user1', 'hello', 'm1'),
140+
FlowEngine.processMessage(ctx, xyz, 'xyz', 'user1', 'hello', 'm2'),
141+
]);
142+
const greetings = replies.filter(r => r.text === xyz.greeting).length;
143+
assert.equal(greetings, 1, 'the greeting must be sent once; a load/save race would send it twice');
144+
});
145+
146+
test('a stored path landing on a now-leaf node ends the flow instead of looping invalid-option', async () => {
147+
const { ctx, storage, replies } = makeCtx();
148+
// Config changed under the user: they are parked at option "1", which is a leaf with no sub-options.
149+
// The old behaviour re-saved state and replied "Invalid option" forever; it must now end cleanly.
150+
storage.set('state__xyz__user1', { path: ['1'], lastActive: Date.now() });
151+
const r = await FlowEngine.processMessage(ctx, xyz, 'xyz', 'user1', 'anything', 'm1');
152+
assert.equal(r, false);
153+
assert.equal(storage.has('state__xyz__user1'), false);
154+
assert.equal(replies.length, 0);
155+
});
156+
135157
test('a prototype-property name as input is an invalid option, never a false match', async () => {
136158
// `abc.options` is a plain object, so a bare `options["constructor"]` would resolve to the inherited
137159
// Object constructor (truthy) and reply with `nextNode.text === undefined`. Object.hasOwn prevents that.

chat-flow/flow-engine.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,13 @@ export interface UserState {
1919
export class FlowEngine {
2020
private static readonly TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes state expiration
2121
private static readonly MAX_REPROCESS = 1; // bound the invalid-path reset (no unbounded recursion)
22+
/** Per (session,chat) promise chain serializing the state read→write. Self-evicts when drained. */
23+
private static readonly locks = new Map<string, Promise<unknown>>();
2224

2325
/**
2426
* Process an incoming message and send auto-replies according to `flow` (the resolved per-session
25-
* config). Returns true if a reply was sent, false otherwise.
27+
* config). Returns true if a reply was sent, false otherwise. Serializes per (session, chat) so
28+
* concurrent messages for the same chat can't interleave the state read→write.
2629
*/
2730
public static async processMessage(
2831
context: PluginContext,
@@ -31,6 +34,29 @@ export class FlowEngine {
3134
chatId: string,
3235
messageBody: string,
3336
messageId: string,
37+
): Promise<boolean> {
38+
// The bounded re-process inside the body calls processLocked directly (bypassing this lock) so a
39+
// chat never waits on its own still-pending chain entry (self-deadlock). Store a settled tail so a
40+
// rejection can't wedge the chain, and evict the key once the chain drains.
41+
const lockKey = `${sessionId}__${chatId}`;
42+
const prev = this.locks.get(lockKey) ?? Promise.resolve();
43+
const run = prev.then(() => this.processLocked(context, flow, sessionId, chatId, messageBody, messageId, 0));
44+
const tail = run.catch(() => {});
45+
this.locks.set(lockKey, tail);
46+
try {
47+
return await run;
48+
} finally {
49+
if (this.locks.get(lockKey) === tail) this.locks.delete(lockKey);
50+
}
51+
}
52+
53+
private static async processLocked(
54+
context: PluginContext,
55+
flow: SessionFlow,
56+
sessionId: string,
57+
chatId: string,
58+
messageBody: string,
59+
messageId: string,
3460
depth = 0,
3561
): Promise<boolean> {
3662
context.logger.debug('[FlowEngine] Processing message', { sessionId, chatId, body: messageBody });
@@ -86,7 +112,9 @@ export class FlowEngine {
86112
context.logger.debug('[FlowEngine] Max reprocess depth reached; not recursing.', { depth });
87113
return false;
88114
}
89-
return this.processMessage(context, flow, sessionId, chatId, messageBody, messageId, depth + 1);
115+
// Recurse on the locked body, NOT processMessage — re-entering the lock would deadlock on this
116+
// chat's own still-pending chain entry.
117+
return this.processLocked(context, flow, sessionId, chatId, messageBody, messageId, depth + 1);
90118
}
91119
}
92120

@@ -114,6 +142,12 @@ export class FlowEngine {
114142
await context.storage.delete(stateKey);
115143
}
116144
return true;
145+
} else if (!currentNode.options || Object.keys(currentNode.options).length === 0) {
146+
// The resolved node is a leaf with no way forward (config changed under the user). End the flow
147+
// instead of looping "Invalid option" forever; the next trigger starts cleanly.
148+
context.logger.debug('[FlowEngine] Resolved node is a dead leaf (config changed). Ending flow.');
149+
await context.storage.delete(stateKey);
150+
return false;
117151
} else {
118152
context.logger.debug('[FlowEngine] Input did not match any options. Replying with fallback.');
119153
const invalidMsg = `Invalid option. Please choose one of the available options:\n\n${currentNode.text}`;

chat-flow/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"id": "chat-flow",
33
"name": "Chat Flow",
4-
"version": "1.0.2",
4+
"version": "1.0.3",
55
"type": "extension",
66
"main": "dist/index.js",
77
"description": "Interactive, stateful auto-reply: a trigger word starts a greeting + numbered menu, replies traverse a configurable menu tree, and per-chat state expires after 15 minutes.",

plugins.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@
197197
{
198198
"id": "chat-flow",
199199
"name": "Chat Flow",
200-
"version": "1.0.2",
200+
"version": "1.0.3",
201201
"type": "extension",
202202
"status": "stable",
203203
"description": "Interactive, stateful auto-reply: a trigger word starts a greeting + numbered menu, replies traverse a configurable menu tree, and per-chat state expires after 15 minutes.",
@@ -218,7 +218,7 @@
218218
"repoPath": "chat-flow",
219219
"repoUrl": "https://github.com/rmyndharis/OpenWA-plugins",
220220
"homepage": "https://github.com/rmyndharis/OpenWA-plugins/tree/main/chat-flow",
221-
"download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/chat-flow-v1.0.2/chat-flow.zip",
221+
"download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/chat-flow-v1.0.3/chat-flow.zip",
222222
"i18n": {
223223
"es": {
224224
"name": "Flujo de Chat",

0 commit comments

Comments
 (0)