@ozaiya/openclaw-channel 0.10.7 → 0.10.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,6 +22,10 @@ import { maybeTranscribeInboundAudio, prependVoiceTranscriptToAgentInput, resolv
22
22
  import { startGatewayMode } from "./gateway.js";
23
23
  import { VoiceCallSession } from "./voiceCall.js";
24
24
  import { PhoneCallSession } from "./phoneCall.js";
25
+ import { summarizeWithYuanbao } from "./yuanbao.js";
26
+ import { summarizeWithDoubao } from "./doubao.js";
27
+ import { fetchXueqiuPost, searchXueqiuPosts } from "./xueqiu.js";
28
+ import { fetchSocialMediaPost, searchSocialMedia, extractSocialMediaContent } from "./socialMedia.js";
25
29
  const DEFAULT_API_BASE_URL = "https://api.ozai.dev";
26
30
  const DEFAULT_WEBHOOK_PATH = "/ozaiya/webhook";
27
31
  const DEFAULT_ACCOUNT_ID = "default";
@@ -710,6 +714,47 @@ async function stageInboundAttachmentsForAgent(params) {
710
714
  }
711
715
  return staged;
712
716
  }
717
+ /**
718
+ * Build the full set of channel agent tools for a given bot account.
719
+ * Used by both the plugin's agentTools factory (for OpenClaw tool registration)
720
+ * and the text-based tool call fallback in deliver().
721
+ */
722
+ function buildChannelTools(account, cfg) {
723
+ const resolveForGroup = (groupId) => resolveAccountForGroup(cfg, groupId) ?? account;
724
+ const tools = [
725
+ createScheduleMessageTool(account),
726
+ createSendDirectMessageTool(account),
727
+ createSendRichMessageTool(account, resolveForGroup),
728
+ createCreateGroupTool(account),
729
+ createInviteMemberTool(account),
730
+ createReactTool(account),
731
+ createEditMessageTool(account),
732
+ createDeleteMessageTool(account),
733
+ createPinMessageTool(account),
734
+ createSearchUsersTool(account),
735
+ createListGroupsTool(account),
736
+ createMakePhoneCallTool(account, cfg),
737
+ createHangUpCallTool(account),
738
+ createStartInAppCallTool(account, cfg),
739
+ ];
740
+ const imageTool = createGenerateImageTool(account, cfg, resolveForGroup);
741
+ if (imageTool)
742
+ tools.push(imageTool);
743
+ tools.push(createDesktopTool());
744
+ const xueqiuTool = createFetchXueqiuTool(cfg);
745
+ if (xueqiuTool)
746
+ tools.push(xueqiuTool);
747
+ const xueqiuSearchTool = createSearchXueqiuTool(cfg);
748
+ if (xueqiuSearchTool)
749
+ tools.push(xueqiuSearchTool);
750
+ const scrapeTool = createScrapeSocialMediaTool(cfg);
751
+ if (scrapeTool)
752
+ tools.push(scrapeTool);
753
+ const summarizeTool = createSummarizeUrlTool(cfg);
754
+ if (summarizeTool)
755
+ tools.push(summarizeTool);
756
+ return tools;
757
+ }
713
758
  export const ozaiyaPlugin = {
714
759
  id: "ozaiya",
715
760
  meta: {
@@ -947,28 +992,7 @@ export const ozaiyaPlugin = {
947
992
  const account = (gatewayAccount?.botToken?.trim() ? gatewayAccount : null) ?? resolveAccount(cfg);
948
993
  if (!account.botToken?.trim())
949
994
  return [];
950
- const resolveForGroup = (groupId) => resolveAccountForGroup(cfg, groupId) ?? account;
951
- const tools = [
952
- createScheduleMessageTool(account),
953
- createSendDirectMessageTool(account),
954
- createSendRichMessageTool(account, resolveForGroup),
955
- createCreateGroupTool(account),
956
- createInviteMemberTool(account),
957
- createReactTool(account),
958
- createEditMessageTool(account),
959
- createDeleteMessageTool(account),
960
- createPinMessageTool(account),
961
- createSearchUsersTool(account),
962
- createListGroupsTool(account),
963
- createMakePhoneCallTool(account, cfg),
964
- createHangUpCallTool(account),
965
- createStartInAppCallTool(account, cfg),
966
- ];
967
- const imageTool = createGenerateImageTool(account, cfg, resolveForGroup);
968
- if (imageTool)
969
- tools.push(imageTool);
970
- tools.push(createDesktopTool());
971
- return tools;
995
+ return buildChannelTools(account, cfg);
972
996
  }),
973
997
  gateway: {
974
998
  startAccount: async (ctx) => {
@@ -1154,6 +1178,15 @@ export const ozaiyaPlugin = {
1154
1178
  if (payload.event === "session.reset") {
1155
1179
  await handleSessionReset(payload, ctx);
1156
1180
  }
1181
+ else if (payload.event === "call.started") {
1182
+ await handleCallStarted(payload, ctx);
1183
+ }
1184
+ else if (payload.event === "call.ended") {
1185
+ await handleCallEnded(payload, ctx);
1186
+ }
1187
+ else if (payload.event === "voice.llm_request") {
1188
+ return handleVoiceLlmRequest(payload, ctx);
1189
+ }
1157
1190
  },
1158
1191
  });
1159
1192
  let unregisterHttp = () => { };
@@ -2101,6 +2134,189 @@ function createHangUpCallTool(account) {
2101
2134
  },
2102
2135
  };
2103
2136
  }
2137
+ function createFetchXueqiuTool(cfg) {
2138
+ const ozaiyaCfg = (cfg?.channels?.ozaiya ?? {});
2139
+ const xueqiuCfg = ozaiyaCfg.xueqiu;
2140
+ const socialMediaCfg = ozaiyaCfg.socialMedia;
2141
+ if (!xueqiuCfg && !socialMediaCfg)
2142
+ return null;
2143
+ return {
2144
+ label: "Fetch Xueqiu Post",
2145
+ name: "fetch_xueqiu",
2146
+ ownerOnly: false,
2147
+ description: "Fetch a Xueqiu (雪球) post's content and top comments given its URL. " +
2148
+ "URL format: https://xueqiu.com/{userId}/{postId}. " +
2149
+ "Returns the post title, author, engagement stats, full text, and top comments.",
2150
+ parameters: {
2151
+ type: "object",
2152
+ properties: {
2153
+ url: {
2154
+ type: "string",
2155
+ description: "The Xueqiu post URL, e.g. https://xueqiu.com/1234567890/123456789",
2156
+ },
2157
+ },
2158
+ required: ["url"],
2159
+ },
2160
+ execute: async (_toolCallId, rawArgs) => {
2161
+ try {
2162
+ const args = rawArgs;
2163
+ // Try media-fetch-api first
2164
+ if (socialMediaCfg) {
2165
+ const result = await fetchSocialMediaPost(args.url, socialMediaCfg);
2166
+ if (result) {
2167
+ return { content: [{ type: "text", text: result }] };
2168
+ }
2169
+ }
2170
+ // Fallback to TS implementation
2171
+ if (xueqiuCfg) {
2172
+ const result = await fetchXueqiuPost(args.url, xueqiuCfg);
2173
+ if (result) {
2174
+ return { content: [{ type: "text", text: result }] };
2175
+ }
2176
+ }
2177
+ return { content: [{ type: "text", text: "Failed to fetch Xueqiu post. The URL may be invalid or the post may not exist." }] };
2178
+ }
2179
+ catch (err) {
2180
+ const msg = err instanceof Error ? err.message : String(err);
2181
+ return { content: [{ type: "text", text: `Error fetching Xueqiu post: ${msg}` }] };
2182
+ }
2183
+ },
2184
+ };
2185
+ }
2186
+ function createSearchXueqiuTool(cfg) {
2187
+ const ozaiyaCfg = (cfg?.channels?.ozaiya ?? {});
2188
+ const xueqiuCfg = ozaiyaCfg.xueqiu;
2189
+ const socialMediaCfg = ozaiyaCfg.socialMedia;
2190
+ if (!xueqiuCfg && !socialMediaCfg)
2191
+ return null;
2192
+ return {
2193
+ label: "Search Xueqiu",
2194
+ name: "search_xueqiu",
2195
+ ownerOnly: false,
2196
+ description: "Search Xueqiu (雪球) for posts matching a keyword or stock name. " +
2197
+ "Use this when the user asks about stocks, investments, or financial topics on Xueqiu. " +
2198
+ "Returns a list of matching posts with authors, dates, excerpts, and URLs. " +
2199
+ "You can then use fetch_xueqiu with a specific URL to get the full post content and comments.",
2200
+ parameters: {
2201
+ type: "object",
2202
+ properties: {
2203
+ query: {
2204
+ type: "string",
2205
+ description: "Search keyword — a stock name (e.g. '华特气体'), stock code (e.g. 'SZ002549'), or topic",
2206
+ },
2207
+ count: {
2208
+ type: "number",
2209
+ description: "Number of results to return (default: 5, max: 10)",
2210
+ },
2211
+ },
2212
+ required: ["query"],
2213
+ },
2214
+ execute: async (_toolCallId, rawArgs) => {
2215
+ try {
2216
+ const args = rawArgs;
2217
+ const count = Math.min(Math.max(args.count ?? 5, 1), 10);
2218
+ // Try media-fetch-api first
2219
+ if (socialMediaCfg) {
2220
+ const result = await searchSocialMedia(args.query, "xueqiu", socialMediaCfg, undefined, count);
2221
+ if (result) {
2222
+ return { content: [{ type: "text", text: result }] };
2223
+ }
2224
+ }
2225
+ // Fallback to TS implementation
2226
+ if (xueqiuCfg) {
2227
+ const result = await searchXueqiuPosts(args.query, xueqiuCfg, undefined, count);
2228
+ if (result) {
2229
+ return { content: [{ type: "text", text: result }] };
2230
+ }
2231
+ }
2232
+ return { content: [{ type: "text", text: `Failed to search Xueqiu for "${args.query}". The service may be temporarily unavailable.` }] };
2233
+ }
2234
+ catch (err) {
2235
+ const msg = err instanceof Error ? err.message : String(err);
2236
+ return { content: [{ type: "text", text: `Error searching Xueqiu: ${msg}` }] };
2237
+ }
2238
+ },
2239
+ };
2240
+ }
2241
+ function createScrapeSocialMediaTool(cfg) {
2242
+ const ozaiyaCfg = (cfg?.channels?.ozaiya ?? {});
2243
+ const socialMediaCfg = ozaiyaCfg.socialMedia;
2244
+ if (!socialMediaCfg)
2245
+ return null;
2246
+ return {
2247
+ label: "Scrape Social Media Post",
2248
+ name: "scrape_social_media",
2249
+ ownerOnly: false,
2250
+ description: "Scrape a social media post via browser automation — returns structured data including title, author, stats, full content text, and top comments. " +
2251
+ "Supports Xiaohongshu (小红书), Douyin (抖音), Bilibili (B站), Weibo (微博), Toutiao (头条), and Xueqiu (雪球) URLs.",
2252
+ parameters: {
2253
+ type: "object",
2254
+ properties: {
2255
+ url: {
2256
+ type: "string",
2257
+ description: "The social media post URL, e.g. https://www.xiaohongshu.com/explore/abc123, " +
2258
+ "https://www.bilibili.com/video/BVxxx, https://m.weibo.cn/detail/xxx, " +
2259
+ "https://www.douyin.com/video/xxx",
2260
+ },
2261
+ },
2262
+ required: ["url"],
2263
+ },
2264
+ execute: async (_toolCallId, rawArgs) => {
2265
+ try {
2266
+ const args = rawArgs;
2267
+ const result = await fetchSocialMediaPost(args.url, socialMediaCfg);
2268
+ if (!result) {
2269
+ return { content: [{ type: "text", text: "Failed to fetch social media post. The URL may be invalid or the service is unavailable." }] };
2270
+ }
2271
+ return { content: [{ type: "text", text: result }] };
2272
+ }
2273
+ catch (err) {
2274
+ const msg = err instanceof Error ? err.message : String(err);
2275
+ return { content: [{ type: "text", text: `Error fetching social media post: ${msg}` }] };
2276
+ }
2277
+ },
2278
+ };
2279
+ }
2280
+ function createSummarizeUrlTool(cfg) {
2281
+ const ozaiyaCfg = (cfg?.channels?.ozaiya ?? {});
2282
+ const socialMediaCfg = ozaiyaCfg.socialMedia;
2283
+ if (!socialMediaCfg)
2284
+ return null;
2285
+ return {
2286
+ label: "Summarize URL Content",
2287
+ name: "summarize_url",
2288
+ ownerOnly: false,
2289
+ description: "Summarize a URL's content using an LLM backend (not a scraper). " +
2290
+ "Works with any URL but especially useful for WeChat articles (mp.weixin.qq.com), " +
2291
+ "Toutiao (toutiao.com), Douyin (douyin.com), and other Chinese social media. " +
2292
+ "Use this when scrape_social_media fails or when you need an LLM-generated summary instead of raw structured data.",
2293
+ parameters: {
2294
+ type: "object",
2295
+ properties: {
2296
+ url: {
2297
+ type: "string",
2298
+ description: "The URL to summarize, e.g. https://mp.weixin.qq.com/s/xxx, " +
2299
+ "https://www.toutiao.com/article/xxx, https://www.douyin.com/video/xxx",
2300
+ },
2301
+ },
2302
+ required: ["url"],
2303
+ },
2304
+ execute: async (_toolCallId, rawArgs) => {
2305
+ try {
2306
+ const args = rawArgs;
2307
+ const result = await extractSocialMediaContent(args.url, socialMediaCfg);
2308
+ if (!result) {
2309
+ return { content: [{ type: "text", text: "Failed to extract content. The URL may be invalid, the backend may be unavailable, or the backend is not configured for this URL type." }] };
2310
+ }
2311
+ return { content: [{ type: "text", text: result }] };
2312
+ }
2313
+ catch (err) {
2314
+ const msg = err instanceof Error ? err.message : String(err);
2315
+ return { content: [{ type: "text", text: `Error extracting content: ${msg}` }] };
2316
+ }
2317
+ },
2318
+ };
2319
+ }
2104
2320
  /**
2105
2321
  * Handle phone transcription in auto mode: dispatch to agent, speak the reply.
2106
2322
  */
@@ -2245,6 +2461,153 @@ ctx) {
2245
2461
  }
2246
2462
  }
2247
2463
  }
2464
+ // Detect WeChat article URLs and enrich with Yuanbao summary
2465
+ const wechatUrlMatch = messageText?.match(/https?:\/\/mp\.weixin\.qq\.com\/s\/[A-Za-z0-9_-]+/);
2466
+ if (wechatUrlMatch) {
2467
+ const ozaiyaCfg = (ctx.cfg?.channels?.ozaiya ?? {});
2468
+ const yuanbaoCfg = ozaiyaCfg.yuanbao;
2469
+ if (yuanbaoCfg) {
2470
+ ctx.log?.info?.(`ozaiya: detected WeChat article URL, calling Yuanbao for summary`);
2471
+ try {
2472
+ const summary = await summarizeWithYuanbao(wechatUrlMatch[0], yuanbaoCfg, ctx.log);
2473
+ if (summary) {
2474
+ content.text = `${content.text}\n\n[WeChat Article Summary via Yuanbao]\n${summary}`;
2475
+ }
2476
+ }
2477
+ catch (err) {
2478
+ ctx.log?.warn?.(`ozaiya: Yuanbao summarization failed: ${err}`);
2479
+ }
2480
+ }
2481
+ }
2482
+ // Detect Toutiao URLs — try media-fetch-api first, fallback to Doubao
2483
+ const toutiaoUrlMatch = messageText?.match(/https?:\/\/(?:www\.)?toutiao\.com\/(?:article|video)\/\d+/);
2484
+ if (toutiaoUrlMatch) {
2485
+ const ozaiyaCfg = (ctx.cfg?.channels?.ozaiya ?? {});
2486
+ const socialMediaCfg = ozaiyaCfg.socialMedia;
2487
+ let fetched = false;
2488
+ if (socialMediaCfg) {
2489
+ ctx.log?.info?.(`ozaiya: detected Toutiao URL, fetching via media-fetch-api`);
2490
+ try {
2491
+ const postContent = await fetchSocialMediaPost(toutiaoUrlMatch[0], socialMediaCfg, ctx.log);
2492
+ if (postContent) {
2493
+ content.text = `${content.text}\n\n${postContent}`;
2494
+ fetched = true;
2495
+ }
2496
+ }
2497
+ catch (err) {
2498
+ ctx.log?.warn?.(`ozaiya: media-fetch-api Toutiao fetch failed: ${err}`);
2499
+ }
2500
+ }
2501
+ if (!fetched) {
2502
+ const doubaoCfg = ozaiyaCfg.doubao;
2503
+ if (doubaoCfg) {
2504
+ ctx.log?.info?.(`ozaiya: Toutiao media-fetch-api failed, falling back to Doubao`);
2505
+ try {
2506
+ const summary = await summarizeWithDoubao(toutiaoUrlMatch[0], doubaoCfg, ctx.log);
2507
+ if (summary) {
2508
+ content.text = `${content.text}\n\n[Content Summary via Doubao]\n${summary}`;
2509
+ }
2510
+ }
2511
+ catch (err) {
2512
+ ctx.log?.warn?.(`ozaiya: Doubao summarization failed: ${err}`);
2513
+ }
2514
+ }
2515
+ }
2516
+ }
2517
+ // Detect Douyin URLs — try media-fetch-api first, fallback to Doubao
2518
+ const douyinUrlMatch = messageText?.match(/https?:\/\/(?:v\.douyin\.com\/[a-zA-Z0-9_\-]+\/?|(?:www\.)?douyin\.com\/(?:video|note)\/\d+)/);
2519
+ if (douyinUrlMatch) {
2520
+ const ozaiyaCfg = (ctx.cfg?.channels?.ozaiya ?? {});
2521
+ const socialMediaCfg = ozaiyaCfg.socialMedia;
2522
+ let fetched = false;
2523
+ if (socialMediaCfg) {
2524
+ ctx.log?.info?.(`ozaiya: detected Douyin URL, fetching via media-fetch-api`);
2525
+ try {
2526
+ const postContent = await fetchSocialMediaPost(douyinUrlMatch[0], socialMediaCfg, ctx.log);
2527
+ if (postContent) {
2528
+ content.text = `${content.text}\n\n[抖音]\n${postContent}`;
2529
+ fetched = true;
2530
+ }
2531
+ }
2532
+ catch (err) {
2533
+ ctx.log?.warn?.(`ozaiya: media-fetch-api Douyin fetch failed: ${err}`);
2534
+ }
2535
+ }
2536
+ if (!fetched) {
2537
+ const doubaoCfg = ozaiyaCfg.doubao;
2538
+ if (doubaoCfg) {
2539
+ ctx.log?.info?.(`ozaiya: Douyin media-fetch-api failed, falling back to Doubao`);
2540
+ try {
2541
+ const summary = await summarizeWithDoubao(douyinUrlMatch[0], doubaoCfg, ctx.log);
2542
+ if (summary) {
2543
+ content.text = `${content.text}\n\n[Content Summary via Doubao]\n${summary}`;
2544
+ }
2545
+ }
2546
+ catch (err) {
2547
+ ctx.log?.warn?.(`ozaiya: Doubao summarization failed: ${err}`);
2548
+ }
2549
+ }
2550
+ }
2551
+ }
2552
+ // Detect social media URLs (XHS, Bilibili, Weibo) and enrich via media-fetch-api
2553
+ const socialMediaUrlMatch = messageText?.match(/https?:\/\/(?:(?:www\.)?(?:xiaohongshu\.com\/(?:explore|discovery\/item)\/[a-zA-Z0-9]+|xhslink\.com\/[a-zA-Z0-9\/]+)|(?:www\.)?bilibili\.com\/video\/(?:BV[a-zA-Z0-9]+|av\d+)|b23\.tv\/[a-zA-Z0-9]+|(?:www\.)?weibo\.com\/\d+\/[a-zA-Z0-9]+|m\.weibo\.cn\/(?:detail|status)\/\d+)/);
2554
+ if (socialMediaUrlMatch) {
2555
+ const ozaiyaCfg = (ctx.cfg?.channels?.ozaiya ?? {});
2556
+ const socialMediaCfg = ozaiyaCfg.socialMedia;
2557
+ if (socialMediaCfg) {
2558
+ const matchedUrl = socialMediaUrlMatch[0];
2559
+ const platformHint = matchedUrl.includes("bilibili.com") || matchedUrl.includes("b23.tv")
2560
+ ? "B站"
2561
+ : matchedUrl.includes("weibo.c")
2562
+ ? "微博"
2563
+ : "小红书";
2564
+ ctx.log?.info?.(`ozaiya: detected ${platformHint} URL, fetching content via media-fetch-api`);
2565
+ try {
2566
+ const postContent = await fetchSocialMediaPost(matchedUrl, socialMediaCfg, ctx.log);
2567
+ if (postContent) {
2568
+ content.text = `${content.text}\n\n[${platformHint}]\n${postContent}`;
2569
+ }
2570
+ }
2571
+ catch (err) {
2572
+ ctx.log?.warn?.(`ozaiya: social media fetch failed: ${err}`);
2573
+ }
2574
+ }
2575
+ }
2576
+ // Detect Xueqiu post URLs — try media-fetch-api first, fallback to TS CDP implementation
2577
+ const xueqiuUrlMatch = messageText?.match(/https?:\/\/xueqiu\.com\/\d+\/\d+/);
2578
+ if (xueqiuUrlMatch) {
2579
+ const ozaiyaCfg = (ctx.cfg?.channels?.ozaiya ?? {});
2580
+ const socialMediaCfg = ozaiyaCfg.socialMedia;
2581
+ let fetched = false;
2582
+ if (socialMediaCfg) {
2583
+ ctx.log?.info?.(`ozaiya: detected Xueqiu URL, fetching via media-fetch-api`);
2584
+ try {
2585
+ const postContent = await fetchSocialMediaPost(xueqiuUrlMatch[0], socialMediaCfg, ctx.log);
2586
+ if (postContent) {
2587
+ content.text = `${content.text}\n\n[雪球]\n${postContent}`;
2588
+ fetched = true;
2589
+ }
2590
+ }
2591
+ catch (err) {
2592
+ ctx.log?.warn?.(`ozaiya: media-fetch-api Xueqiu fetch failed: ${err}`);
2593
+ }
2594
+ }
2595
+ if (!fetched) {
2596
+ const xueqiuCfg = ozaiyaCfg.xueqiu;
2597
+ if (xueqiuCfg) {
2598
+ ctx.log?.info?.(`ozaiya: Xueqiu media-fetch-api unavailable, falling back to TS implementation`);
2599
+ try {
2600
+ const postContent = await fetchXueqiuPost(xueqiuUrlMatch[0], xueqiuCfg, ctx.log);
2601
+ if (postContent) {
2602
+ content.text = `${content.text}\n\n[雪球]\n${postContent}`;
2603
+ }
2604
+ }
2605
+ catch (err) {
2606
+ ctx.log?.warn?.(`ozaiya: Xueqiu TS fetch failed: ${err}`);
2607
+ }
2608
+ }
2609
+ }
2610
+ }
2248
2611
  const inboundAttachments = normalizeAttachments(content.files);
2249
2612
  const attachmentSummary = buildAttachmentSummary(inboundAttachments);
2250
2613
  const linkPreviewSummary = buildLinkPreviewSummary(content.linkPreviews);
@@ -2425,6 +2788,13 @@ ctx) {
2425
2788
  }).catch((err) => {
2426
2789
  ctx.log?.warn?.(`ozaiya: failed recording session: ${String(err)}`);
2427
2790
  });
2791
+ // Build channel tools map for text-based tool call fallback.
2792
+ // When a model outputs tool calls as plain text instead of structured API tool_calls,
2793
+ // we match against registered tool names and execute via their .execute() method.
2794
+ // Uses the current bot's account directly (not the factory which re-resolves in gateway mode).
2795
+ const channelTools = buildChannelTools(account, ctx.cfg);
2796
+ const channelToolsByName = new Map(channelTools.map((t) => [t.name, t]));
2797
+ ctx.log?.info?.(`ozaiya: text fallback tools loaded: ${channelToolsByName.size} tools [${[...channelToolsByName.keys()].join(", ")}]`);
2428
2798
  // Dispatch to agent with buffered block dispatcher
2429
2799
  await ch.reply.dispatchReplyWithBufferedBlockDispatcher({
2430
2800
  ctx: msgCtx,
@@ -2435,15 +2805,59 @@ ctx) {
2435
2805
  ctx.log?.info?.(`ozaiya: deliver called, text length=${replyText?.length ?? 0}, empty=${!replyText?.trim()}, voiceReply=${voiceReply}, voiceReplyVoice=${voiceReplyVoice ?? 'none'}`);
2436
2806
  if (!replyText?.trim())
2437
2807
  return;
2438
- // Handle XML tool calls that some providers return as text instead of API tool_calls.
2439
- // Parse <function_calls><invoke name="..."><parameter name="...">...</parameter></invoke></function_calls>
2440
- // and execute matching Ozaiya tools directly.
2808
+ // Generic fallback: intercept tool calls that models output as text
2809
+ // instead of structured API tool_calls. Supports two formats:
2810
+ // 1. JSON function syntax: tool_name({"arg":"value"}) or tool_name({arg: "value"})
2811
+ // 2. XML: <function_calls><invoke name="tool_name"><parameter name="arg">value</parameter></invoke></function_calls>
2812
+ let textToolsExecuted = false;
2813
+ // --- Format 1: JSON function syntax tool_name({"key":"val"}) ---
2814
+ if (channelToolsByName.size > 0) {
2815
+ const toolNames = [...channelToolsByName.keys()].map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
2816
+ const jsonFnRegex = new RegExp(`\\b(${toolNames.join("|")})\\s*\\(\\s*(\\{[\\s\\S]*?\\})\\s*\\)`, "g");
2817
+ let jsonMatch;
2818
+ while ((jsonMatch = jsonFnRegex.exec(replyText)) !== null) {
2819
+ const toolName = jsonMatch[1];
2820
+ const tool = channelToolsByName.get(toolName);
2821
+ if (!tool)
2822
+ continue;
2823
+ // Always strip the tool call text from the message — users should never
2824
+ // see raw tool_name({...}) syntax even if execution fails.
2825
+ textToolsExecuted = true;
2826
+ try {
2827
+ // Try strict JSON first, then lenient (unquoted keys, trailing commas)
2828
+ let args;
2829
+ try {
2830
+ args = JSON.parse(jsonMatch[2]);
2831
+ }
2832
+ catch {
2833
+ // Handle JS-style object literals: unquoted keys, trailing commas
2834
+ const lenient = jsonMatch[2]
2835
+ .replace(/([{,]\s*)([a-zA-Z_]\w*)\s*:/g, '$1"$2":')
2836
+ .replace(/,\s*}/g, "}");
2837
+ args = JSON.parse(lenient);
2838
+ }
2839
+ ctx.log?.info?.(`ozaiya: text fallback — executing ${toolName}(${JSON.stringify(args)})`);
2840
+ await tool.execute(`text-fallback-${Date.now()}`, args);
2841
+ }
2842
+ catch (err) {
2843
+ ctx.log?.warn?.(`ozaiya: text fallback — ${toolName} failed: ${String(err)}`);
2844
+ }
2845
+ }
2846
+ if (textToolsExecuted) {
2847
+ replyText = replyText.replace(jsonFnRegex, "").trim();
2848
+ if (!replyText)
2849
+ return;
2850
+ }
2851
+ }
2852
+ // --- Format 2: XML <function_calls> ---
2441
2853
  if (replyText.includes("<function_calls>") && replyText.includes("<invoke")) {
2442
2854
  const invokeRegex = /<invoke\s+name="([^"]+)">([\s\S]*?)<\/invoke>/g;
2443
2855
  let match;
2444
- let executedTools = false;
2445
2856
  while ((match = invokeRegex.exec(replyText)) !== null) {
2446
2857
  const toolName = match[1];
2858
+ const tool = channelToolsByName.get(toolName);
2859
+ if (!tool)
2860
+ continue;
2447
2861
  const paramsXml = match[2];
2448
2862
  const paramRegex = /<parameter\s+name="([^"]+)">([^<]*)<\/parameter>/g;
2449
2863
  const args = {};
@@ -2451,71 +2865,25 @@ ctx) {
2451
2865
  while ((pm = paramRegex.exec(paramsXml)) !== null) {
2452
2866
  args[pm[1]] = pm[2];
2453
2867
  }
2454
- ctx.log?.info?.(`ozaiya: parsed XML tool call: ${toolName}(${JSON.stringify(args)})`);
2455
- if (toolName === "start_in_app_call" && args.groupId) {
2456
- try {
2457
- const callResult = await startCall(account.apiBaseUrl, account.botToken, args.groupId, args.type ?? "voice");
2458
- ctx.log?.info?.(`ozaiya: XML tool call executed: start_in_app_call for group ${args.groupId}`);
2459
- executedTools = true;
2460
- // Connect bot to LiveKit for STT/TTS (standard engine)
2461
- if (callResult && callResult.voiceEngine === "standard" && !callResult.joined) {
2462
- if (activeVoiceCalls.has(callResult.callId)) {
2463
- ctx.log?.info?.(`ozaiya: already in call ${callResult.callId}, skipping`);
2464
- }
2465
- else {
2466
- const cfg = ctx.cfg;
2467
- const ozaiyaCfg = (cfg?.channels?.ozaiya ?? {});
2468
- const voiceCallCfg = ozaiyaCfg.voiceCall ?? {};
2469
- if (voiceCallCfg.enabled !== false) {
2470
- const effectiveVoiceCallCfg = { ...voiceCallCfg };
2471
- if (account.voiceConfig?.provider === "deepgram" && account.voiceConfig.apiKey) {
2472
- effectiveVoiceCallCfg.deepgramApiKey = account.voiceConfig.apiKey;
2473
- }
2474
- if (!effectiveVoiceCallCfg.volcengineTts && ozaiyaCfg.volcengineTts) {
2475
- effectiveVoiceCallCfg.volcengineTts = ozaiyaCfg.volcengineTts;
2476
- }
2477
- const session = new VoiceCallSession({
2478
- callId: callResult.callId,
2479
- groupId: args.groupId,
2480
- livekitToken: callResult.token,
2481
- livekitUrl: callResult.url,
2482
- voiceCallConfig: effectiveVoiceCallCfg,
2483
- onTranscript: (text) => {
2484
- void handleVoiceTranscript(text, session, route, account, { cfg, log: ctx.log });
2485
- },
2486
- log: ctx.log,
2487
- });
2488
- activeVoiceCalls.set(callResult.callId, session);
2489
- try {
2490
- await session.connect();
2491
- ctx.log?.info?.(`ozaiya: bot joined LiveKit room for call ${callResult.callId}`);
2492
- }
2493
- catch (connErr) {
2494
- activeVoiceCalls.delete(callResult.callId);
2495
- await session.disconnect();
2496
- await leaveCall(account.apiBaseUrl, account.botToken, callResult.callId).catch(() => { });
2497
- ctx.log?.warn?.(`ozaiya: failed to join LiveKit: ${String(connErr)}`);
2498
- }
2499
- }
2500
- }
2501
- }
2502
- }
2503
- catch (err) {
2504
- ctx.log?.warn?.(`ozaiya: XML tool call failed: ${String(err)}`);
2505
- }
2868
+ ctx.log?.info?.(`ozaiya: text fallback (XML) executing ${toolName}(${JSON.stringify(args)})`);
2869
+ try {
2870
+ await tool.execute(`text-fallback-xml-${Date.now()}`, args);
2871
+ textToolsExecuted = true;
2872
+ }
2873
+ catch (err) {
2874
+ ctx.log?.warn?.(`ozaiya: text fallback (XML) ${toolName} failed: ${String(err)}`);
2506
2875
  }
2507
2876
  }
2508
- // Strip XML function_calls from reply text, keep any remaining text
2509
2877
  replyText = replyText
2510
2878
  .replace(/<function_calls>[\s\S]*?<\/function_calls>/g, "")
2511
2879
  .replace(/<function_results>[\s\S]*?<\/function_results>/g, "")
2512
2880
  .replace(/\[\[reply_to_current\]\]/g, "")
2513
2881
  .replace(/NO_REPLY/g, "")
2514
2882
  .trim();
2515
- if (!replyText && executedTools)
2516
- return; // Tool executed, no text to send
2883
+ if (!replyText && textToolsExecuted)
2884
+ return;
2517
2885
  if (!replyText)
2518
- return; // Nothing left after stripping
2886
+ return;
2519
2887
  }
2520
2888
  // Voice reply: synthesize TTS audio and send as voice message
2521
2889
  if (voiceReply) {