googlechat: implement typing indicator via message editing

This commit is contained in:
iHildy
2026-01-24 20:16:14 +00:00
committed by Peter Steinberger
parent 70e7034a1c
commit c64184fcfa
4 changed files with 97 additions and 8 deletions

View File

@@ -94,6 +94,20 @@ export async function sendGoogleChatMessage(params: {
return result ? { messageName: result.name } : null;
}
export async function updateGoogleChatMessage(params: {
account: ResolvedGoogleChatAccount;
messageName: string;
text: string;
}): Promise<{ messageName?: string }> {
const { account, messageName, text } = params;
const url = `${CHAT_API_BASE}/${messageName}?updateMask=text`;
const result = await fetchJson<{ name?: string }>(account, url, {
method: "PATCH",
body: JSON.stringify({ text }),
});
return { messageName: result.name };
}
export async function uploadGoogleChatAttachment(params: {
account: ResolvedGoogleChatAccount;
space: string;

View File

@@ -9,6 +9,7 @@ import {
import {
downloadGoogleChatMedia,
sendGoogleChatMessage,
updateGoogleChatMessage,
} from "./api.js";
import { verifyGoogleChatRequest, type GoogleChatAudienceType } from "./auth.js";
import { getGoogleChatRuntime } from "./runtime.js";
@@ -344,6 +345,24 @@ function extractMentionInfo(annotations: GoogleChatAnnotation[], botUser?: strin
return { hasAnyMention, wasMentioned };
}
/**
* Resolve bot display name with fallback chain:
* 1. Account config name
* 2. Agent name from config
* 3. "Clawdbot" as generic fallback
*/
function resolveBotDisplayName(params: {
accountName?: string;
agentId: string;
config: ClawdbotConfig;
}): string {
const { accountName, agentId, config } = params;
if (accountName?.trim()) return accountName.trim();
const agent = config.agents?.list?.find((a) => a.id === agentId);
if (agent?.name?.trim()) return agent.name.trim();
return "Clawdbot";
}
async function processMessageWithPipeline(params: {
event: GoogleChatEvent;
account: ResolvedGoogleChatAccount;
@@ -614,6 +633,38 @@ async function processMessageWithPipeline(params: {
runtime.error?.(`googlechat: failed updating session meta: ${String(err)}`);
});
// Typing indicator setup
// Note: Reaction mode requires user OAuth, not available with service account auth.
// If reaction is configured, we fall back to message mode with a warning.
let typingIndicator = account.config.typingIndicator ?? "message";
if (typingIndicator === "reaction") {
runtime.error?.(
`[${account.accountId}] typingIndicator="reaction" requires user OAuth (not supported with service account). Falling back to "message" mode.`,
);
typingIndicator = "message";
}
let typingMessageName: string | undefined;
// Start typing indicator (message mode only, reaction mode not supported with app auth)
if (typingIndicator === "message") {
try {
const botName = resolveBotDisplayName({
accountName: account.config.name,
agentId: route.agentId,
config,
});
const result = await sendGoogleChatMessage({
account,
space: spaceId,
text: `_${botName} is typing..._`,
thread: message.thread?.name,
});
typingMessageName = result?.messageName;
} catch (err) {
runtime.error?.(`Failed sending typing message: ${String(err)}`);
}
}
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config,
@@ -626,7 +677,10 @@ async function processMessageWithPipeline(params: {
runtime,
core,
statusSink,
typingMessageName,
});
// Only use typing message for first delivery
typingMessageName = undefined;
},
onError: (err, info) => {
runtime.error?.(
@@ -664,8 +718,9 @@ async function deliverGoogleChatReply(params: {
runtime: GoogleChatRuntimeEnv;
core: GoogleChatCoreRuntime;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
typingMessageName?: string;
}): Promise<void> {
const { payload, account, spaceId, runtime, core, statusSink } = params;
const { payload, account, spaceId, runtime, core, statusSink, typingMessageName } = params;
const mediaList = payload.mediaUrls?.length
? payload.mediaUrls
: payload.mediaUrl
@@ -711,14 +766,24 @@ async function deliverGoogleChatReply(params: {
if (payload.text) {
const chunkLimit = account.config.textChunkLimit ?? 4000;
const chunks = core.channel.text.chunkMarkdownText(payload.text, chunkLimit);
for (const chunk of chunks) {
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
try {
await sendGoogleChatMessage({
account,
space: spaceId,
text: chunk,
thread: payload.replyToId,
});
// Edit typing message with first chunk if available
if (i === 0 && typingMessageName) {
await updateGoogleChatMessage({
account,
messageName: typingMessageName,
text: chunk,
});
} else {
await sendGoogleChatMessage({
account,
space: spaceId,
text: chunk,
thread: payload.replyToId,
});
}
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
runtime.error?.(`Google Chat message send failed: ${String(err)}`);

View File

@@ -87,6 +87,15 @@ export type GoogleChatAccountConfig = {
/** Per-action tool gating (default: true for all). */
actions?: GoogleChatActionConfig;
dm?: GoogleChatDmConfig;
/**
* Typing indicator mode (default: "message").
* - "none": No indicator
* - "message": Send "_<name> is typing..._" then edit with response
* - "reaction": React with 👀 to user message, remove on reply
* NOTE: Reaction mode requires user OAuth (not supported with service account auth).
* If configured, falls back to message mode with a warning.
*/
typingIndicator?: "none" | "message" | "reaction";
};
export type GoogleChatConfig = {

View File

@@ -321,6 +321,7 @@ export const GoogleChatAccountSchema = z
.strict()
.optional(),
dm: GoogleChatDmSchema.optional(),
typingIndicator: z.enum(["none", "message", "reaction"]).optional(),
})
.strict();