googlechat: implement typing indicator via message editing
This commit is contained in:
committed by
Peter Steinberger
parent
70e7034a1c
commit
c64184fcfa
@@ -94,6 +94,20 @@ export async function sendGoogleChatMessage(params: {
|
|||||||
return result ? { messageName: result.name } : null;
|
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: {
|
export async function uploadGoogleChatAttachment(params: {
|
||||||
account: ResolvedGoogleChatAccount;
|
account: ResolvedGoogleChatAccount;
|
||||||
space: string;
|
space: string;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
downloadGoogleChatMedia,
|
downloadGoogleChatMedia,
|
||||||
sendGoogleChatMessage,
|
sendGoogleChatMessage,
|
||||||
|
updateGoogleChatMessage,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { verifyGoogleChatRequest, type GoogleChatAudienceType } from "./auth.js";
|
import { verifyGoogleChatRequest, type GoogleChatAudienceType } from "./auth.js";
|
||||||
import { getGoogleChatRuntime } from "./runtime.js";
|
import { getGoogleChatRuntime } from "./runtime.js";
|
||||||
@@ -344,6 +345,24 @@ function extractMentionInfo(annotations: GoogleChatAnnotation[], botUser?: strin
|
|||||||
return { hasAnyMention, wasMentioned };
|
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: {
|
async function processMessageWithPipeline(params: {
|
||||||
event: GoogleChatEvent;
|
event: GoogleChatEvent;
|
||||||
account: ResolvedGoogleChatAccount;
|
account: ResolvedGoogleChatAccount;
|
||||||
@@ -614,6 +633,38 @@ async function processMessageWithPipeline(params: {
|
|||||||
runtime.error?.(`googlechat: failed updating session meta: ${String(err)}`);
|
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({
|
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||||
ctx: ctxPayload,
|
ctx: ctxPayload,
|
||||||
cfg: config,
|
cfg: config,
|
||||||
@@ -626,7 +677,10 @@ async function processMessageWithPipeline(params: {
|
|||||||
runtime,
|
runtime,
|
||||||
core,
|
core,
|
||||||
statusSink,
|
statusSink,
|
||||||
|
typingMessageName,
|
||||||
});
|
});
|
||||||
|
// Only use typing message for first delivery
|
||||||
|
typingMessageName = undefined;
|
||||||
},
|
},
|
||||||
onError: (err, info) => {
|
onError: (err, info) => {
|
||||||
runtime.error?.(
|
runtime.error?.(
|
||||||
@@ -664,8 +718,9 @@ async function deliverGoogleChatReply(params: {
|
|||||||
runtime: GoogleChatRuntimeEnv;
|
runtime: GoogleChatRuntimeEnv;
|
||||||
core: GoogleChatCoreRuntime;
|
core: GoogleChatCoreRuntime;
|
||||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||||
|
typingMessageName?: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { payload, account, spaceId, runtime, core, statusSink } = params;
|
const { payload, account, spaceId, runtime, core, statusSink, typingMessageName } = params;
|
||||||
const mediaList = payload.mediaUrls?.length
|
const mediaList = payload.mediaUrls?.length
|
||||||
? payload.mediaUrls
|
? payload.mediaUrls
|
||||||
: payload.mediaUrl
|
: payload.mediaUrl
|
||||||
@@ -711,14 +766,24 @@ async function deliverGoogleChatReply(params: {
|
|||||||
if (payload.text) {
|
if (payload.text) {
|
||||||
const chunkLimit = account.config.textChunkLimit ?? 4000;
|
const chunkLimit = account.config.textChunkLimit ?? 4000;
|
||||||
const chunks = core.channel.text.chunkMarkdownText(payload.text, chunkLimit);
|
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 {
|
try {
|
||||||
await sendGoogleChatMessage({
|
// Edit typing message with first chunk if available
|
||||||
account,
|
if (i === 0 && typingMessageName) {
|
||||||
space: spaceId,
|
await updateGoogleChatMessage({
|
||||||
text: chunk,
|
account,
|
||||||
thread: payload.replyToId,
|
messageName: typingMessageName,
|
||||||
});
|
text: chunk,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await sendGoogleChatMessage({
|
||||||
|
account,
|
||||||
|
space: spaceId,
|
||||||
|
text: chunk,
|
||||||
|
thread: payload.replyToId,
|
||||||
|
});
|
||||||
|
}
|
||||||
statusSink?.({ lastOutboundAt: Date.now() });
|
statusSink?.({ lastOutboundAt: Date.now() });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
runtime.error?.(`Google Chat message send failed: ${String(err)}`);
|
runtime.error?.(`Google Chat message send failed: ${String(err)}`);
|
||||||
|
|||||||
@@ -87,6 +87,15 @@ export type GoogleChatAccountConfig = {
|
|||||||
/** Per-action tool gating (default: true for all). */
|
/** Per-action tool gating (default: true for all). */
|
||||||
actions?: GoogleChatActionConfig;
|
actions?: GoogleChatActionConfig;
|
||||||
dm?: GoogleChatDmConfig;
|
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 = {
|
export type GoogleChatConfig = {
|
||||||
|
|||||||
@@ -321,6 +321,7 @@ export const GoogleChatAccountSchema = z
|
|||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
dm: GoogleChatDmSchema.optional(),
|
dm: GoogleChatDmSchema.optional(),
|
||||||
|
typingIndicator: z.enum(["none", "message", "reaction"]).optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user