fix(ringcentral): prevent infinite loop from bot's own messages
- Track sent message IDs to skip processing bot's replies
- Fix chat type detection (Personal vs PersonalChat vs Direct)
- Skip typing/thinking indicator messages by pattern
- Allow owner in selfOnly mode to bypass DM policy check
- Change typing indicator to '⏳ _Clawdbot is thinking..._'
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
parent
44b27d1ba0
commit
f9d6ec7106
@ -26,6 +26,19 @@ export type RingCentralRuntimeEnv = {
|
||||
error?: (message: string) => void;
|
||||
};
|
||||
|
||||
// Track recently sent message IDs to avoid processing bot's own replies
|
||||
const recentlySentMessageIds = new Set<string>();
|
||||
const MESSAGE_ID_TTL = 60000; // 60 seconds
|
||||
|
||||
function trackSentMessageId(messageId: string): void {
|
||||
recentlySentMessageIds.add(messageId);
|
||||
setTimeout(() => recentlySentMessageIds.delete(messageId), MESSAGE_ID_TTL);
|
||||
}
|
||||
|
||||
function isOwnSentMessage(messageId: string): boolean {
|
||||
return recentlySentMessageIds.has(messageId);
|
||||
}
|
||||
|
||||
export type RingCentralMonitorOptions = {
|
||||
account: ResolvedRingCentralAccount;
|
||||
config: ClawdbotConfig;
|
||||
@ -124,8 +137,14 @@ async function processWebSocketEvent(params: {
|
||||
const eventBody = event.body;
|
||||
if (!eventBody) return;
|
||||
|
||||
// Check event type - can be from eventType field or inferred from event path
|
||||
const eventType = eventBody.eventType;
|
||||
if (eventType !== "PostAdded") return;
|
||||
const eventPath = event.event ?? "";
|
||||
const isPostEvent = eventPath.includes("/glip/posts") || eventPath.includes("/team-messaging") || eventType === "PostAdded";
|
||||
|
||||
if (!isPostEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
statusSink?.({ lastInboundAt: Date.now() });
|
||||
|
||||
@ -162,16 +181,34 @@ async function processMessageWithPipeline(params: {
|
||||
const rawBody = messageText || (hasMedia ? "<media:attachment>" : "");
|
||||
if (!rawBody) return;
|
||||
|
||||
// Skip bot's own messages to avoid infinite loop
|
||||
// Check 1: Skip if this is a message we recently sent
|
||||
const messageId = eventBody.id ?? "";
|
||||
if (messageId && isOwnSentMessage(messageId)) {
|
||||
logVerbose(core, runtime, `skip own sent message: ${messageId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check 2: Skip typing/thinking indicators (pattern-based)
|
||||
if (rawBody.includes("thinking...") || rawBody.includes("typing...")) {
|
||||
logVerbose(core, runtime, "skip typing indicator message");
|
||||
return;
|
||||
}
|
||||
|
||||
// In JWT mode (selfOnly), only accept messages from the JWT user themselves
|
||||
// This is because the bot uses the JWT user's identity, so we're essentially
|
||||
// having a conversation with ourselves (the AI assistant)
|
||||
const selfOnly = account.config.selfOnly !== false; // default true
|
||||
runtime.log?.(`[${account.accountId}] Processing message: senderId=${senderId}, ownerId=${ownerId}, selfOnly=${selfOnly}, chatId=${chatId}, text=${rawBody.slice(0, 50)}`);
|
||||
|
||||
if (selfOnly && ownerId) {
|
||||
if (senderId !== ownerId) {
|
||||
logVerbose(core, runtime, `ignore message from non-owner: ${senderId} (selfOnly mode)`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
runtime.log?.(`[${account.accountId}] Message passed selfOnly check`);
|
||||
|
||||
// Fetch chat info to determine type
|
||||
let chatType = "Group";
|
||||
@ -184,7 +221,9 @@ async function processMessageWithPipeline(params: {
|
||||
// If we can't fetch chat info, assume it's a group
|
||||
}
|
||||
|
||||
const isGroup = chatType !== "Direct" && chatType !== "PersonalChat";
|
||||
// Personal, PersonalChat, Direct are all DM types
|
||||
const isGroup = chatType !== "Direct" && chatType !== "PersonalChat" && chatType !== "Personal";
|
||||
runtime.log?.(`[${account.accountId}] Chat type: ${chatType}, isGroup: ${isGroup}`);
|
||||
|
||||
const defaultGroupPolicy = config.channels?.defaults?.groupPolicy;
|
||||
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||
@ -287,7 +326,10 @@ async function processMessageWithPipeline(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (dmPolicy !== "open") {
|
||||
// In selfOnly mode, always allow the owner
|
||||
const isOwner = selfOnly && ownerId && senderId === ownerId;
|
||||
|
||||
if (dmPolicy !== "open" && !isOwner) {
|
||||
const allowed = senderAllowedForCommands;
|
||||
if (!allowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
@ -425,9 +467,11 @@ async function processMessageWithPipeline(params: {
|
||||
const result = await sendRingCentralMessage({
|
||||
account,
|
||||
chatId,
|
||||
text: `_${botName} is typing..._`,
|
||||
text: `⏳ _${botName} is thinking..._`,
|
||||
});
|
||||
typingPostId = result?.postId;
|
||||
// Track sent message to avoid processing it as incoming
|
||||
if (typingPostId) trackSentMessageId(typingPostId);
|
||||
} catch (err) {
|
||||
runtime.error?.(`Failed sending typing message: ${String(err)}`);
|
||||
}
|
||||
@ -542,12 +586,13 @@ async function deliverRingCentralReply(params: {
|
||||
if (!upload.attachmentId) {
|
||||
throw new Error("missing attachment id");
|
||||
}
|
||||
await sendRingCentralMessage({
|
||||
const sendResult = await sendRingCentralMessage({
|
||||
account,
|
||||
chatId,
|
||||
text: caption,
|
||||
attachments: [{ id: upload.attachmentId }],
|
||||
});
|
||||
if (sendResult?.postId) trackSentMessageId(sendResult.postId);
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
} catch (err) {
|
||||
runtime.error?.(`RingCentral attachment send failed: ${String(err)}`);
|
||||
@ -572,18 +617,20 @@ async function deliverRingCentralReply(params: {
|
||||
const chunk = chunks[i];
|
||||
try {
|
||||
if (i === 0 && typingPostId) {
|
||||
await updateRingCentralMessage({
|
||||
const updateResult = await updateRingCentralMessage({
|
||||
account,
|
||||
chatId,
|
||||
postId: typingPostId,
|
||||
text: chunk,
|
||||
});
|
||||
if (updateResult?.postId) trackSentMessageId(updateResult.postId);
|
||||
} else {
|
||||
await sendRingCentralMessage({
|
||||
const sendResult = await sendRingCentralMessage({
|
||||
account,
|
||||
chatId,
|
||||
text: chunk,
|
||||
});
|
||||
if (sendResult?.postId) trackSentMessageId(sendResult.postId);
|
||||
}
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
} catch (err) {
|
||||
@ -622,6 +669,7 @@ export async function startRingCentralMonitor(
|
||||
|
||||
// Handle notifications
|
||||
subscription.on(subscription.events.notification, (event: unknown) => {
|
||||
runtime.log?.(`[${account.accountId}] WebSocket notification received: ${JSON.stringify(event).slice(0, 500)}`);
|
||||
const evt = event as RingCentralWebhookEvent;
|
||||
processWebSocketEvent({
|
||||
event: evt,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user