@ozaiya/openclaw-channel 0.5.0 → 0.7.0

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.
@@ -8,9 +8,9 @@
8
8
  import fs from "node:fs/promises";
9
9
  import path from "node:path";
10
10
  import { registerPluginHttpRoute } from "openclaw/plugin-sdk";
11
- import { unwrapGroupKey, decryptMessage, encryptMessage } from "./crypto.js";
12
- import { sendMessage, probeApi, fetchGroups, toggleReaction, editMessage, deleteMessage, pinMessage, unpinMessage, uploadFile, searchUsers, fetchLinkPreview, } from "./api.js";
13
- import { botCreateDirect } from "./botActions.js";
11
+ import { unwrapGroupKey, decryptMessage, encryptMessage, wrapGroupKey } from "./crypto.js";
12
+ import { sendMessage, probeApi, fetchGroups, addMember, getUserPublicKeys, toggleReaction, editMessage, deleteMessage, pinMessage, unpinMessage, uploadFile, searchUsers, fetchLinkPreview, joinCall, leaveCall, } from "./api.js";
13
+ import { botCreateDirect, botCreateGroup } from "./botActions.js";
14
14
  import { buildInlineKeyboardSummary, buildLinkPreviewSummary, normalizeMessageText, normalizeToolInlineKeyboardRows, } from "./richContent.js";
15
15
  import { normalizeCallbackQueryPayload } from "./callbackQuery.js";
16
16
  import { enrichOutgoingMessageContent } from "./messageEnrichment.js";
@@ -18,6 +18,7 @@ import { createOzaiyaWebhookHandler } from "./webhook.js";
18
18
  import { getOzaiyaRuntime } from "./runtime.js";
19
19
  import { maybeTranscribeInboundAudio, prependVoiceTranscriptToAgentInput, resolveOzaiyaSttConfig, } from "./transcribeAudio.js";
20
20
  import { startGatewayMode } from "./gateway.js";
21
+ import { VoiceCallSession } from "./voiceCall.js";
21
22
  const DEFAULT_API_BASE_URL = "https://api.ozai.dev";
22
23
  const DEFAULT_WEBHOOK_PATH = "/ozaiya/webhook";
23
24
  const DEFAULT_ACCOUNT_ID = "default";
@@ -29,6 +30,8 @@ const RICH_MESSAGE_GUIDANCE = "Prefer plain text for normal prose, code, markdow
29
30
  "and attachments when sending generated files. Standalone media/file URLs, markdown images, and markdown attachment links in plain text replies are auto-uploaded.";
30
31
  // In-memory cache of unwrapped group keys (groupId → Uint8Array)
31
32
  const unwrappedKeys = new Map();
33
+ // Active voice call sessions keyed by callId
34
+ const activeVoiceCalls = new Map();
32
35
  // Runtime state tracking
33
36
  const runtimeState = new Map();
34
37
  function recordState(accountId, patch) {
@@ -614,6 +617,14 @@ export const ozaiyaPlugin = {
614
617
  createScheduleMessageTool(account),
615
618
  createSendDirectMessageTool(account),
616
619
  createSendRichMessageTool(account),
620
+ createCreateGroupTool(account),
621
+ createInviteMemberTool(account),
622
+ createReactTool(account),
623
+ createEditMessageTool(account),
624
+ createDeleteMessageTool(account),
625
+ createPinMessageTool(account),
626
+ createSearchUsersTool(account),
627
+ createListGroupsTool(account),
617
628
  ];
618
629
  }),
619
630
  gateway: {
@@ -654,6 +665,12 @@ export const ozaiyaPlugin = {
654
665
  else if (payload.event === "session.reset") {
655
666
  await handleSessionReset(payload, botCtx);
656
667
  }
668
+ else if (payload.event === "call.started") {
669
+ await handleCallStarted(payload, botCtx);
670
+ }
671
+ else if (payload.event === "call.ended") {
672
+ await handleCallEnded(payload, botCtx);
673
+ }
657
674
  },
658
675
  }),
659
676
  });
@@ -689,6 +706,37 @@ export const ozaiyaPlugin = {
689
706
  startBotHandler(botAccount);
690
707
  }
691
708
  },
709
+ onWebhookEvent: (botId, payload) => {
710
+ const botAccount = gatewayBotAccounts.get(botId);
711
+ if (!botAccount)
712
+ return;
713
+ const botCtx = { ...ctx, account: botAccount };
714
+ if (payload.event === "message.new") {
715
+ handleInboundMessage(payload, botCtx).catch((err) => {
716
+ ctx.log?.warn?.(`[${botId}] Socket.io webhook error: ${String(err)}`);
717
+ });
718
+ }
719
+ else if (payload.event === "callback_query") {
720
+ handleCallbackQuery(payload, botCtx).catch((err) => {
721
+ ctx.log?.warn?.(`[${botId}] Socket.io callback_query error: ${String(err)}`);
722
+ });
723
+ }
724
+ else if (payload.event === "session.reset") {
725
+ handleSessionReset(payload, botCtx).catch((err) => {
726
+ ctx.log?.warn?.(`[${botId}] Socket.io session.reset error: ${String(err)}`);
727
+ });
728
+ }
729
+ else if (payload.event === "call.started") {
730
+ handleCallStarted(payload, botCtx).catch((err) => {
731
+ ctx.log?.warn?.(`[${botId}] Socket.io call.started error: ${String(err)}`);
732
+ });
733
+ }
734
+ else if (payload.event === "call.ended") {
735
+ handleCallEnded(payload, botCtx).catch((err) => {
736
+ ctx.log?.warn?.(`[${botId}] Socket.io call.ended error: ${String(err)}`);
737
+ });
738
+ }
739
+ },
692
740
  log: {
693
741
  info: (msg) => ctx.log?.info(msg),
694
742
  warn: (msg) => ctx.log?.warn?.(msg),
@@ -698,6 +746,11 @@ export const ozaiyaPlugin = {
698
746
  for (const unregister of botUnregisters.values())
699
747
  unregister();
700
748
  botUnregisters.clear();
749
+ // Disconnect all active voice call sessions
750
+ for (const [callId, session] of activeVoiceCalls) {
751
+ session.disconnect().catch(() => { });
752
+ activeVoiceCalls.delete(callId);
753
+ }
701
754
  for (const id of gatewayBotAccounts.keys()) {
702
755
  recordState(id, { running: false, lastStopAt: Date.now() });
703
756
  }
@@ -1079,6 +1132,299 @@ function createSendRichMessageTool(account) {
1079
1132
  },
1080
1133
  };
1081
1134
  }
1135
+ function createCreateGroupTool(account) {
1136
+ return {
1137
+ label: "Create Group",
1138
+ name: "create_group",
1139
+ ownerOnly: false,
1140
+ description: "Create a new Ozaiya group chat and optionally invite members by their Ozaiya ID or account ID. " +
1141
+ "Returns the groupId and groupCode. The bot is automatically added as the group owner.",
1142
+ parameters: {
1143
+ type: "object",
1144
+ properties: {
1145
+ name: {
1146
+ type: "string",
1147
+ description: "The group name.",
1148
+ },
1149
+ memberIds: {
1150
+ type: "array",
1151
+ items: { type: "string" },
1152
+ description: "Optional list of user Ozaiya IDs or account IDs to invite to the group.",
1153
+ },
1154
+ },
1155
+ required: ["name"],
1156
+ },
1157
+ execute: async (_toolCallId, rawArgs) => {
1158
+ try {
1159
+ const args = rawArgs;
1160
+ const groupName = args.name?.trim();
1161
+ if (!groupName) {
1162
+ return { content: [{ type: "text", text: "Error: group name is required." }] };
1163
+ }
1164
+ // Resolve ozaiyaIds to account IDs
1165
+ let accountIds;
1166
+ if (args.memberIds && args.memberIds.length > 0) {
1167
+ accountIds = [];
1168
+ for (const id of args.memberIds) {
1169
+ const users = await searchUsers(account.apiBaseUrl, account.botToken, id).catch(() => []);
1170
+ const match = users.find((u) => u.ozaiyaId === id) ?? users[0];
1171
+ accountIds.push(match ? match.id : id);
1172
+ }
1173
+ }
1174
+ const { groupId, groupCode, groupKey } = await botCreateGroup({
1175
+ apiBaseUrl: account.apiBaseUrl,
1176
+ botToken: account.botToken,
1177
+ botPrivateKey: account.botPrivateKey,
1178
+ webhookSecret: account.webhookSecret,
1179
+ }, groupName, accountIds);
1180
+ // Cache the group key for immediate message sending
1181
+ unwrappedKeys.set(groupId, groupKey);
1182
+ const memberInfo = accountIds?.length
1183
+ ? `, invited ${accountIds.length} member(s)`
1184
+ : "";
1185
+ return {
1186
+ content: [{
1187
+ type: "text",
1188
+ text: `Group "${groupName}" created (groupId: ${groupId}, groupCode: ${groupCode}${memberInfo}).`,
1189
+ }],
1190
+ };
1191
+ }
1192
+ catch (err) {
1193
+ const msg = err instanceof Error ? err.message : String(err);
1194
+ return { content: [{ type: "text", text: `Error: ${msg}` }] };
1195
+ }
1196
+ },
1197
+ };
1198
+ }
1199
+ function createInviteMemberTool(account) {
1200
+ return {
1201
+ label: "Invite Member",
1202
+ name: "invite_member",
1203
+ ownerOnly: false,
1204
+ description: "Invite a user to an existing group chat by their Ozaiya ID or account ID. " +
1205
+ "The bot must be a member of the group and have the group key cached.",
1206
+ parameters: {
1207
+ type: "object",
1208
+ properties: {
1209
+ groupId: { type: "string", description: "The group ID to invite the user to." },
1210
+ userId: { type: "string", description: "The target user's Ozaiya ID or account ID." },
1211
+ },
1212
+ required: ["groupId", "userId"],
1213
+ },
1214
+ execute: async (_toolCallId, rawArgs) => {
1215
+ try {
1216
+ const args = rawArgs;
1217
+ const groupId = args.groupId?.trim();
1218
+ const userId = args.userId?.trim();
1219
+ if (!groupId || !userId) {
1220
+ return { content: [{ type: "text", text: "Error: groupId and userId are required." }] };
1221
+ }
1222
+ // Resolve ozaiyaId to account ID
1223
+ let accountId = userId;
1224
+ const users = await searchUsers(account.apiBaseUrl, account.botToken, userId).catch(() => []);
1225
+ const match = users.find((u) => u.ozaiyaId === userId) ?? users[0];
1226
+ if (match)
1227
+ accountId = match.id;
1228
+ // Get group key and wrap for the new member
1229
+ const groupKey = await getGroupKeyOrThrow(account, groupId);
1230
+ const pubKeys = await getUserPublicKeys(account.apiBaseUrl, account.botToken, [accountId]);
1231
+ if (pubKeys.length === 0) {
1232
+ return { content: [{ type: "text", text: `Error: user ${userId} not found or has no encryption key.` }] };
1233
+ }
1234
+ const wrappedKey = await wrapGroupKey(groupKey, pubKeys[0].publicKey);
1235
+ await addMember(account.apiBaseUrl, account.botToken, groupId, accountId, wrappedKey);
1236
+ return {
1237
+ content: [{ type: "text", text: `Invited ${userId} to group ${groupId}.` }],
1238
+ };
1239
+ }
1240
+ catch (err) {
1241
+ const msg = err instanceof Error ? err.message : String(err);
1242
+ return { content: [{ type: "text", text: `Error: ${msg}` }] };
1243
+ }
1244
+ },
1245
+ };
1246
+ }
1247
+ function createReactTool(account) {
1248
+ return {
1249
+ label: "React",
1250
+ name: "react",
1251
+ ownerOnly: false,
1252
+ description: "Add or remove an emoji reaction on a message. Toggles: if the reaction already exists, it is removed.",
1253
+ parameters: {
1254
+ type: "object",
1255
+ properties: {
1256
+ messageId: { type: "string", description: "The message ID to react to." },
1257
+ emoji: { type: "string", description: "The emoji to react with (e.g. '👍', '❤️', '🎉')." },
1258
+ },
1259
+ required: ["messageId", "emoji"],
1260
+ },
1261
+ execute: async (_toolCallId, rawArgs) => {
1262
+ try {
1263
+ const args = rawArgs;
1264
+ const { added } = await toggleReaction(account.apiBaseUrl, account.botToken, args.messageId, args.emoji);
1265
+ return {
1266
+ content: [{ type: "text", text: added ? `Reacted with ${args.emoji}.` : `Removed ${args.emoji} reaction.` }],
1267
+ };
1268
+ }
1269
+ catch (err) {
1270
+ const msg = err instanceof Error ? err.message : String(err);
1271
+ return { content: [{ type: "text", text: `Error: ${msg}` }] };
1272
+ }
1273
+ },
1274
+ };
1275
+ }
1276
+ function createEditMessageTool(account) {
1277
+ return {
1278
+ label: "Edit Message",
1279
+ name: "edit_message",
1280
+ ownerOnly: false,
1281
+ description: "Edit a previously sent message. Only the bot's own messages can be edited.",
1282
+ parameters: {
1283
+ type: "object",
1284
+ properties: {
1285
+ groupId: { type: "string", description: "The group ID containing the message." },
1286
+ messageId: { type: "string", description: "The message ID to edit." },
1287
+ text: { type: "string", description: "The new message text." },
1288
+ },
1289
+ required: ["groupId", "messageId", "text"],
1290
+ },
1291
+ execute: async (_toolCallId, rawArgs) => {
1292
+ try {
1293
+ const args = rawArgs;
1294
+ const groupKey = await getGroupKeyOrThrow(account, args.groupId);
1295
+ const content = { text: args.text };
1296
+ const encrypted = encryptMessage(content, groupKey);
1297
+ await editMessage(account.apiBaseUrl, account.botToken, args.messageId, encrypted);
1298
+ return {
1299
+ content: [{ type: "text", text: `Message ${args.messageId} edited.` }],
1300
+ };
1301
+ }
1302
+ catch (err) {
1303
+ const msg = err instanceof Error ? err.message : String(err);
1304
+ return { content: [{ type: "text", text: `Error: ${msg}` }] };
1305
+ }
1306
+ },
1307
+ };
1308
+ }
1309
+ function createDeleteMessageTool(account) {
1310
+ return {
1311
+ label: "Delete Message",
1312
+ name: "delete_message",
1313
+ ownerOnly: false,
1314
+ description: "Delete a previously sent message. Only the bot's own messages can be deleted.",
1315
+ parameters: {
1316
+ type: "object",
1317
+ properties: {
1318
+ messageId: { type: "string", description: "The message ID to delete." },
1319
+ },
1320
+ required: ["messageId"],
1321
+ },
1322
+ execute: async (_toolCallId, rawArgs) => {
1323
+ try {
1324
+ const args = rawArgs;
1325
+ await deleteMessage(account.apiBaseUrl, account.botToken, args.messageId);
1326
+ return {
1327
+ content: [{ type: "text", text: `Message ${args.messageId} deleted.` }],
1328
+ };
1329
+ }
1330
+ catch (err) {
1331
+ const msg = err instanceof Error ? err.message : String(err);
1332
+ return { content: [{ type: "text", text: `Error: ${msg}` }] };
1333
+ }
1334
+ },
1335
+ };
1336
+ }
1337
+ function createPinMessageTool(account) {
1338
+ return {
1339
+ label: "Pin Message",
1340
+ name: "pin_message",
1341
+ ownerOnly: false,
1342
+ description: "Pin or unpin a message in a group chat.",
1343
+ parameters: {
1344
+ type: "object",
1345
+ properties: {
1346
+ messageId: { type: "string", description: "The message ID to pin or unpin." },
1347
+ unpin: { type: "boolean", description: "Set to true to unpin instead of pin. Default: false." },
1348
+ },
1349
+ required: ["messageId"],
1350
+ },
1351
+ execute: async (_toolCallId, rawArgs) => {
1352
+ try {
1353
+ const args = rawArgs;
1354
+ if (args.unpin) {
1355
+ await unpinMessage(account.apiBaseUrl, account.botToken, args.messageId);
1356
+ return { content: [{ type: "text", text: `Message ${args.messageId} unpinned.` }] };
1357
+ }
1358
+ await pinMessage(account.apiBaseUrl, account.botToken, args.messageId);
1359
+ return { content: [{ type: "text", text: `Message ${args.messageId} pinned.` }] };
1360
+ }
1361
+ catch (err) {
1362
+ const msg = err instanceof Error ? err.message : String(err);
1363
+ return { content: [{ type: "text", text: `Error: ${msg}` }] };
1364
+ }
1365
+ },
1366
+ };
1367
+ }
1368
+ function createSearchUsersTool(account) {
1369
+ return {
1370
+ label: "Search Users",
1371
+ name: "search_users",
1372
+ ownerOnly: false,
1373
+ description: "Search for Ozaiya users by name or Ozaiya ID. Returns matching user IDs and display names.",
1374
+ parameters: {
1375
+ type: "object",
1376
+ properties: {
1377
+ query: { type: "string", description: "The search query (name or Ozaiya ID)." },
1378
+ },
1379
+ required: ["query"],
1380
+ },
1381
+ execute: async (_toolCallId, rawArgs) => {
1382
+ try {
1383
+ const args = rawArgs;
1384
+ const users = await searchUsers(account.apiBaseUrl, account.botToken, args.query);
1385
+ if (users.length === 0) {
1386
+ return { content: [{ type: "text", text: "No users found." }] };
1387
+ }
1388
+ const lines = users.map((u) => `- ${u.ozaiyaId ?? "(no ID)"} (accountId: ${u.id}, nickname: ${u.nickname ?? "-"})`);
1389
+ return {
1390
+ content: [{ type: "text", text: `Found ${users.length} user(s):\n${lines.join("\n")}` }],
1391
+ };
1392
+ }
1393
+ catch (err) {
1394
+ const msg = err instanceof Error ? err.message : String(err);
1395
+ return { content: [{ type: "text", text: `Error: ${msg}` }] };
1396
+ }
1397
+ },
1398
+ };
1399
+ }
1400
+ function createListGroupsTool(account) {
1401
+ return {
1402
+ label: "List Groups",
1403
+ name: "list_groups",
1404
+ ownerOnly: false,
1405
+ description: "List all group chats the bot is a member of. Returns group IDs and names.",
1406
+ parameters: {
1407
+ type: "object",
1408
+ properties: {},
1409
+ },
1410
+ execute: async (_toolCallId, _rawArgs) => {
1411
+ try {
1412
+ const groups = await fetchGroups(account.apiBaseUrl, account.botToken);
1413
+ if (groups.length === 0) {
1414
+ return { content: [{ type: "text", text: "Not a member of any groups." }] };
1415
+ }
1416
+ const lines = groups.map((g) => `- ${g.name ?? "(unnamed)"} (groupId: ${g.id})`);
1417
+ return {
1418
+ content: [{ type: "text", text: `Member of ${groups.length} group(s):\n${lines.join("\n")}` }],
1419
+ };
1420
+ }
1421
+ catch (err) {
1422
+ const msg = err instanceof Error ? err.message : String(err);
1423
+ return { content: [{ type: "text", text: `Error: ${msg}` }] };
1424
+ }
1425
+ },
1426
+ };
1427
+ }
1082
1428
  /**
1083
1429
  * Handle an inbound webhook message:
1084
1430
  * 1. Decrypt message content
@@ -1462,4 +1808,168 @@ ctx) {
1462
1808
  },
1463
1809
  });
1464
1810
  }
1811
+ /**
1812
+ * Handle call.started webhook — bot auto-joins the LiveKit room and starts
1813
+ * listening via Deepgram STT, dispatching transcripts to the agent, and
1814
+ * speaking replies via Deepgram Aura TTS.
1815
+ */
1816
+ async function handleCallStarted(payload,
1817
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1818
+ ctx) {
1819
+ const account = ctx.account;
1820
+ const ozaiyaCfg = (ctx.cfg?.channels?.ozaiya ?? {});
1821
+ const voiceCallCfg = ozaiyaCfg.voiceCall;
1822
+ if (!voiceCallCfg?.enabled) {
1823
+ ctx.log?.info?.(`[${account.accountId}] voice call disabled, ignoring call.started`);
1824
+ return;
1825
+ }
1826
+ if (voiceCallCfg.autoJoin === false) {
1827
+ ctx.log?.info?.(`[${account.accountId}] voiceCall.autoJoin=false, ignoring call.started`);
1828
+ return;
1829
+ }
1830
+ const maxConcurrent = voiceCallCfg.maxConcurrentCalls ?? 5;
1831
+ if (activeVoiceCalls.size >= maxConcurrent) {
1832
+ ctx.log?.warn?.(`[${account.accountId}] max concurrent voice calls reached (${maxConcurrent}), ignoring`);
1833
+ return;
1834
+ }
1835
+ if (activeVoiceCalls.has(payload.callId)) {
1836
+ ctx.log?.info?.(`[${account.accountId}] already in call ${payload.callId}`);
1837
+ return;
1838
+ }
1839
+ ctx.log?.info?.(`[${account.accountId}] joining call ${payload.callId} in group ${payload.groupId}`);
1840
+ // Join the call via API to get LiveKit token
1841
+ const joinResult = await joinCall(account.apiBaseUrl, account.botToken, payload.callId);
1842
+ if (!joinResult) {
1843
+ ctx.log?.warn?.(`[${account.accountId}] failed to join call ${payload.callId}`);
1844
+ return;
1845
+ }
1846
+ // Resolve agent route for dispatching voice transcripts
1847
+ const runtime = getOzaiyaRuntime();
1848
+ const ch = runtime.channel;
1849
+ const route = ch.routing.resolveAgentRoute({
1850
+ cfg: ctx.cfg,
1851
+ channel: "ozaiya",
1852
+ accountId: account.accountId,
1853
+ peer: {
1854
+ kind: "group",
1855
+ id: payload.groupId,
1856
+ },
1857
+ });
1858
+ const session = new VoiceCallSession({
1859
+ callId: payload.callId,
1860
+ groupId: payload.groupId,
1861
+ livekitToken: joinResult.token,
1862
+ livekitUrl: joinResult.url,
1863
+ voiceCallConfig: voiceCallCfg,
1864
+ onTranscript: (text) => {
1865
+ // Dispatch transcript to agent and speak the reply
1866
+ void handleVoiceTranscript(text, session, route, account, ctx);
1867
+ },
1868
+ log: ctx.log,
1869
+ });
1870
+ activeVoiceCalls.set(payload.callId, session);
1871
+ try {
1872
+ await session.connect();
1873
+ }
1874
+ catch (err) {
1875
+ ctx.log?.warn?.(`[${account.accountId}] voice call connect error: ${String(err)}`);
1876
+ activeVoiceCalls.delete(payload.callId);
1877
+ await session.disconnect();
1878
+ // Leave the call via API
1879
+ await leaveCall(account.apiBaseUrl, account.botToken, payload.callId).catch(() => { });
1880
+ }
1881
+ }
1882
+ /**
1883
+ * Dispatch a voice transcript to the agent and speak the reply via TTS.
1884
+ */
1885
+ async function handleVoiceTranscript(text, session, route, account,
1886
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1887
+ ctx) {
1888
+ const runtime = getOzaiyaRuntime();
1889
+ const ch = runtime.channel;
1890
+ const fromAddress = `ozaiya:group:${session.groupId}`;
1891
+ const conversationLabel = `group:${session.groupId}`;
1892
+ const storePath = ch.session.resolveStorePath(undefined, {
1893
+ agentId: route.agentId,
1894
+ });
1895
+ const previousTimestamp = ch.session.readSessionUpdatedAt({
1896
+ storePath,
1897
+ sessionKey: route.sessionKey,
1898
+ });
1899
+ const envelopeOptions = ch.reply.resolveEnvelopeFormatOptions(ctx.cfg);
1900
+ // Voice-specific agent prompt — tells the agent to respond in spoken-friendly style
1901
+ const ozaiyaChannelCfg = (ctx.cfg?.channels?.ozaiya ?? {});
1902
+ const voicePrompt = ozaiyaChannelCfg.voiceCall?.agentPrompt ??
1903
+ "[Voice Call] You are in a live voice call. Your response will be spoken aloud via TTS. " +
1904
+ "Rules: respond concisely (1-3 sentences), use natural spoken language, " +
1905
+ "never use markdown/code blocks/bullet lists/URLs/emojis. " +
1906
+ "Do not say \"sure\" or \"of course\" — just answer directly.";
1907
+ const bodyForAgent = `${voicePrompt}\n\n${text}`;
1908
+ const body = ch.reply.formatAgentEnvelope({
1909
+ channel: "Ozaiya",
1910
+ from: `Voice (${conversationLabel})`,
1911
+ timestamp: Date.now(),
1912
+ previousTimestamp,
1913
+ envelope: envelopeOptions,
1914
+ body: bodyForAgent,
1915
+ });
1916
+ const msgCtx = ch.reply.finalizeInboundContext({
1917
+ Body: body,
1918
+ BodyForAgent: bodyForAgent,
1919
+ RawBody: text,
1920
+ CommandBody: text,
1921
+ From: fromAddress,
1922
+ To: fromAddress,
1923
+ SessionKey: route.sessionKey,
1924
+ AccountId: route.accountId,
1925
+ ChatType: "group",
1926
+ ConversationLabel: conversationLabel,
1927
+ GroupSubject: session.groupId,
1928
+ SenderId: "voice-caller",
1929
+ SenderName: "Voice Caller",
1930
+ Provider: "ozaiya",
1931
+ Surface: "ozaiya-voice",
1932
+ MessageSid: `voice-${Date.now()}`,
1933
+ Timestamp: Date.now(),
1934
+ NumFiles: 0,
1935
+ NumMedia: 0,
1936
+ HasFiles: false,
1937
+ CommandAuthorized: true,
1938
+ OriginatingChannel: "ozaiya",
1939
+ OriginatingTo: fromAddress,
1940
+ });
1941
+ // Collect the full reply text, then speak it sentence-by-sentence
1942
+ await ch.reply.dispatchReplyWithBufferedBlockDispatcher({
1943
+ ctx: msgCtx,
1944
+ cfg: ctx.cfg,
1945
+ dispatcherOptions: {
1946
+ deliver: async (replyPayload, _info) => {
1947
+ const replyText = replyPayload.text;
1948
+ if (!replyText?.trim())
1949
+ return;
1950
+ // Speak via TTS (sentence-level streaming handled inside speakReply)
1951
+ await session.speakReply(replyText);
1952
+ },
1953
+ onError: (err) => {
1954
+ ctx.log?.warn?.(`ozaiya: voice reply dispatch error: ${String(err)}`);
1955
+ },
1956
+ },
1957
+ });
1958
+ }
1959
+ /**
1960
+ * Handle call.ended webhook — disconnect the bot's VoiceCallSession.
1961
+ */
1962
+ async function handleCallEnded(payload,
1963
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1964
+ ctx) {
1965
+ const account = ctx.account;
1966
+ const session = activeVoiceCalls.get(payload.callId);
1967
+ if (!session)
1968
+ return;
1969
+ ctx.log?.info?.(`[${account.accountId}] call ended ${payload.callId}, disconnecting voice session`);
1970
+ activeVoiceCalls.delete(payload.callId);
1971
+ await session.disconnect();
1972
+ // Leave the call via API (fire-and-forget)
1973
+ await leaveCall(account.apiBaseUrl, account.botToken, payload.callId).catch(() => { });
1974
+ }
1465
1975
  //# sourceMappingURL=channel.js.map