217 lines
6.7 KiB
JavaScript
217 lines
6.7 KiB
JavaScript
"use strict";
|
||
/**
|
||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||
* SPDX-License-Identifier: MIT
|
||
*
|
||
* Abort trigger detection for the Lark/Feishu channel plugin.
|
||
*
|
||
* Provides a fast-path check to determine whether an inbound message is
|
||
* an abort/stop command *before* it enters the per-chat serial queue.
|
||
*
|
||
* The trigger word list and normalisation logic are copied from the
|
||
* OpenClaw core (`src/auto-reply/reply/abort.ts`) so the plugin can
|
||
* make a lightweight decision without importing the full reply pipeline.
|
||
* The message still flows through `tryFastAbortFromMessage()` for
|
||
* authoritative handling.
|
||
*/
|
||
Object.defineProperty(exports, "__esModule", { value: true });
|
||
exports.isAbortTrigger = isAbortTrigger;
|
||
exports.isLikelyAbortText = isLikelyAbortText;
|
||
exports.isConversationStopIntent = isConversationStopIntent;
|
||
exports.extractRawTextFromEvent = extractRawTextFromEvent;
|
||
// ---------------------------------------------------------------------------
|
||
// Trigger word list (synced with OpenClaw core abort.ts)
|
||
// ---------------------------------------------------------------------------
|
||
const ABORT_TRIGGERS = new Set([
|
||
'stop',
|
||
'esc',
|
||
'abort',
|
||
'wait',
|
||
'exit',
|
||
'interrupt',
|
||
'detente',
|
||
'deten',
|
||
'detén',
|
||
'arrete',
|
||
'arrête',
|
||
'停止',
|
||
'やめて',
|
||
'止めて',
|
||
'रुको',
|
||
'توقف',
|
||
'стоп',
|
||
'остановись',
|
||
'останови',
|
||
'остановить',
|
||
'прекрати',
|
||
'halt',
|
||
'anhalten',
|
||
'aufhören',
|
||
'hoer auf',
|
||
'stopp',
|
||
'pare',
|
||
'stop openclaw',
|
||
'openclaw stop',
|
||
'stop action',
|
||
'stop current action',
|
||
'stop run',
|
||
'stop current run',
|
||
'stop agent',
|
||
'stop the agent',
|
||
"stop don't do anything",
|
||
'stop dont do anything',
|
||
'stop do not do anything',
|
||
'stop doing anything',
|
||
'do not do that',
|
||
'please stop',
|
||
'stop please',
|
||
]);
|
||
// ---------------------------------------------------------------------------
|
||
// Normalisation helpers
|
||
// ---------------------------------------------------------------------------
|
||
const TRAILING_ABORT_PUNCTUATION_RE = /[.!?…,,。;;::'"'")\]}]+$/u;
|
||
function normalizeAbortTriggerText(text) {
|
||
return text
|
||
.trim()
|
||
.toLowerCase()
|
||
.replace(/['`]/g, "'")
|
||
.replace(/\s+/g, ' ')
|
||
.replace(TRAILING_ABORT_PUNCTUATION_RE, '')
|
||
.trim();
|
||
}
|
||
// ---------------------------------------------------------------------------
|
||
// Public API
|
||
// ---------------------------------------------------------------------------
|
||
/** Exact trigger-word match (same logic as OpenClaw core `isAbortTrigger`). */
|
||
function isAbortTrigger(text) {
|
||
if (!text)
|
||
return false;
|
||
const normalized = normalizeAbortTriggerText(text);
|
||
return ABORT_TRIGGERS.has(normalized);
|
||
}
|
||
/**
|
||
* Extended abort detection: matches both bare trigger words and the
|
||
* `/stop` command form. Used by the monitor fast-path.
|
||
*/
|
||
function isLikelyAbortText(text) {
|
||
if (!text)
|
||
return false;
|
||
const trimmed = text.trim().toLowerCase();
|
||
if (trimmed === '/stop')
|
||
return true;
|
||
return isAbortTrigger(trimmed);
|
||
}
|
||
// ---------------------------------------------------------------------------
|
||
// Conversation stop-intent (broader than the exact abort triggers)
|
||
// ---------------------------------------------------------------------------
|
||
/**
|
||
* Conversational "please stop / interrupt this exchange" phrases.
|
||
*
|
||
* Deliberately SEPARATE from {@link ABORT_TRIGGERS} (which is synced word-for-
|
||
* word with OpenClaw core and matched by exact equality, e.g. `/stop`). These
|
||
* are matched by substring so natural phrasings like "中断对话" or "stop
|
||
* talking" are caught. The list is intentionally distinctive to avoid false
|
||
* positives — a false positive only means we skip the deterministic peer-@
|
||
* backstop for that turn (the model can still @ on its own), which is mild.
|
||
*/
|
||
const STOP_INTENT_PHRASES = [
|
||
// zh — stop / terminate / pause
|
||
'中断',
|
||
'中止',
|
||
'终止',
|
||
'停止',
|
||
'停下',
|
||
'停一下',
|
||
'暂停',
|
||
'打住',
|
||
'停手',
|
||
'收手',
|
||
// zh — "don't keep going / replying"
|
||
'别聊',
|
||
'别说了',
|
||
'别回复',
|
||
'别继续',
|
||
'别再聊',
|
||
'别再说',
|
||
'别吵',
|
||
'别争',
|
||
'不要回复',
|
||
'不要继续',
|
||
'不用回复',
|
||
'不用继续',
|
||
// zh — "wrap up / be quiet"
|
||
'结束对话',
|
||
'结束讨论',
|
||
'结束辩论',
|
||
'到此为止',
|
||
'闭嘴',
|
||
// en
|
||
'stop talking',
|
||
'stop chatting',
|
||
'stop debating',
|
||
'stop the debate',
|
||
'stop the conversation',
|
||
'stop this conversation',
|
||
'stop responding',
|
||
'stop replying',
|
||
'end the conversation',
|
||
'end conversation',
|
||
'end the debate',
|
||
'shut up',
|
||
'be quiet',
|
||
'cut it out',
|
||
'knock it off',
|
||
'wrap it up',
|
||
'stand down',
|
||
];
|
||
/**
|
||
* Whether an inbound message expresses intent to stop / interrupt the ongoing
|
||
* (bot-to-bot) exchange. Superset of {@link isLikelyAbortText} plus the
|
||
* conversational phrases above.
|
||
*
|
||
* Two consumers: (1) suppress the deterministic peer-@ backstop so a stop
|
||
* acknowledgement doesn't re-wake the peer bot; (2) mute an active bot loop so
|
||
* the in-flight ping-pong drains instead of being re-armed. Substring match —
|
||
* keep the list distinctive (no bare "停"/"stop") to limit false positives;
|
||
* the worst case is a missed forced-@ or a self-healing mute (any normal
|
||
* message lifts it).
|
||
*/
|
||
function isConversationStopIntent(text) {
|
||
if (!text)
|
||
return false;
|
||
// Drop bot mention placeholders so "@Bot 中断对话" → "中断对话".
|
||
const normalized = text.replace(/@_user_\d+/g, '').trim().toLowerCase();
|
||
if (!normalized)
|
||
return false;
|
||
if (isLikelyAbortText(normalized))
|
||
return true;
|
||
return STOP_INTENT_PHRASES.some((p) => normalized.includes(p));
|
||
}
|
||
/**
|
||
* Extract the raw text payload from a Feishu message event.
|
||
*
|
||
* Only handles `text` type messages. The `message.content` field is a
|
||
* JSON string like `{"text":"hello"}`. Returns `undefined` for
|
||
* non-text messages or parse failures.
|
||
*
|
||
* In group chats, bot mention placeholders (`@_user_N`) are stripped so
|
||
* a message like `@Bot stop` is detected as `stop`.
|
||
*/
|
||
function extractRawTextFromEvent(event) {
|
||
if (!event.message || event.message.message_type !== 'text') {
|
||
return undefined;
|
||
}
|
||
try {
|
||
const parsed = JSON.parse(event.message.content);
|
||
let text = parsed?.text;
|
||
if (typeof text !== 'string')
|
||
return undefined;
|
||
// Strip bot mention placeholders (@_user_1, @_user_2, etc.)
|
||
text = text.replace(/@_user_\d+/g, '').trim();
|
||
return text || undefined;
|
||
}
|
||
catch {
|
||
return undefined;
|
||
}
|
||
}
|