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:
John Lin 2026-01-27 18:14:45 +08:00
parent 44b27d1ba0
commit f9d6ec7106
No known key found for this signature in database

View File

@ -26,6 +26,19 @@ export type RingCentralRuntimeEnv = {
error?: (message: string) => void; 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 = { export type RingCentralMonitorOptions = {
account: ResolvedRingCentralAccount; account: ResolvedRingCentralAccount;
config: ClawdbotConfig; config: ClawdbotConfig;
@ -124,8 +137,14 @@ async function processWebSocketEvent(params: {
const eventBody = event.body; const eventBody = event.body;
if (!eventBody) return; if (!eventBody) return;
// Check event type - can be from eventType field or inferred from event path
const eventType = eventBody.eventType; 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() }); statusSink?.({ lastInboundAt: Date.now() });
@ -162,10 +181,26 @@ async function processMessageWithPipeline(params: {
const rawBody = messageText || (hasMedia ? "<media:attachment>" : ""); const rawBody = messageText || (hasMedia ? "<media:attachment>" : "");
if (!rawBody) return; 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 // 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 // This is because the bot uses the JWT user's identity, so we're essentially
// having a conversation with ourselves (the AI assistant) // having a conversation with ourselves (the AI assistant)
const selfOnly = account.config.selfOnly !== false; // default true 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 (selfOnly && ownerId) {
if (senderId !== ownerId) { if (senderId !== ownerId) {
logVerbose(core, runtime, `ignore message from non-owner: ${senderId} (selfOnly mode)`); logVerbose(core, runtime, `ignore message from non-owner: ${senderId} (selfOnly mode)`);
@ -173,6 +208,8 @@ async function processMessageWithPipeline(params: {
} }
} }
runtime.log?.(`[${account.accountId}] Message passed selfOnly check`);
// Fetch chat info to determine type // Fetch chat info to determine type
let chatType = "Group"; let chatType = "Group";
let chatName: string | undefined; let chatName: string | undefined;
@ -184,7 +221,9 @@ async function processMessageWithPipeline(params: {
// If we can't fetch chat info, assume it's a group // 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 defaultGroupPolicy = config.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
@ -287,7 +326,10 @@ async function processMessageWithPipeline(params: {
return; return;
} }
if (dmPolicy !== "open") { // In selfOnly mode, always allow the owner
const isOwner = selfOnly && ownerId && senderId === ownerId;
if (dmPolicy !== "open" && !isOwner) {
const allowed = senderAllowedForCommands; const allowed = senderAllowedForCommands;
if (!allowed) { if (!allowed) {
if (dmPolicy === "pairing") { if (dmPolicy === "pairing") {
@ -425,9 +467,11 @@ async function processMessageWithPipeline(params: {
const result = await sendRingCentralMessage({ const result = await sendRingCentralMessage({
account, account,
chatId, chatId,
text: `_${botName} is typing..._`, text: `⏳ _${botName} is thinking..._`,
}); });
typingPostId = result?.postId; typingPostId = result?.postId;
// Track sent message to avoid processing it as incoming
if (typingPostId) trackSentMessageId(typingPostId);
} catch (err) { } catch (err) {
runtime.error?.(`Failed sending typing message: ${String(err)}`); runtime.error?.(`Failed sending typing message: ${String(err)}`);
} }
@ -542,12 +586,13 @@ async function deliverRingCentralReply(params: {
if (!upload.attachmentId) { if (!upload.attachmentId) {
throw new Error("missing attachment id"); throw new Error("missing attachment id");
} }
await sendRingCentralMessage({ const sendResult = await sendRingCentralMessage({
account, account,
chatId, chatId,
text: caption, text: caption,
attachments: [{ id: upload.attachmentId }], attachments: [{ id: upload.attachmentId }],
}); });
if (sendResult?.postId) trackSentMessageId(sendResult.postId);
statusSink?.({ lastOutboundAt: Date.now() }); statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) { } catch (err) {
runtime.error?.(`RingCentral attachment send failed: ${String(err)}`); runtime.error?.(`RingCentral attachment send failed: ${String(err)}`);
@ -572,18 +617,20 @@ async function deliverRingCentralReply(params: {
const chunk = chunks[i]; const chunk = chunks[i];
try { try {
if (i === 0 && typingPostId) { if (i === 0 && typingPostId) {
await updateRingCentralMessage({ const updateResult = await updateRingCentralMessage({
account, account,
chatId, chatId,
postId: typingPostId, postId: typingPostId,
text: chunk, text: chunk,
}); });
if (updateResult?.postId) trackSentMessageId(updateResult.postId);
} else { } else {
await sendRingCentralMessage({ const sendResult = await sendRingCentralMessage({
account, account,
chatId, chatId,
text: chunk, text: chunk,
}); });
if (sendResult?.postId) trackSentMessageId(sendResult.postId);
} }
statusSink?.({ lastOutboundAt: Date.now() }); statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) { } catch (err) {
@ -622,6 +669,7 @@ export async function startRingCentralMonitor(
// Handle notifications // Handle notifications
subscription.on(subscription.events.notification, (event: unknown) => { 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; const evt = event as RingCentralWebhookEvent;
processWebSocketEvent({ processWebSocketEvent({
event: evt, event: evt,