@chrisromp/copilot-bridge 0.8.5 → 0.9.1
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.
- package/README.md +8 -0
- package/dist/channels/mattermost/streaming.d.ts +2 -0
- package/dist/channels/mattermost/streaming.d.ts.map +1 -1
- package/dist/channels/mattermost/streaming.js +13 -2
- package/dist/channels/mattermost/streaming.js.map +1 -1
- package/dist/core/bridge.d.ts +5 -0
- package/dist/core/bridge.d.ts.map +1 -1
- package/dist/core/bridge.js +4 -0
- package/dist/core/bridge.js.map +1 -1
- package/dist/core/command-handler.d.ts +1 -1
- package/dist/core/command-handler.d.ts.map +1 -1
- package/dist/core/command-handler.js +14 -1
- package/dist/core/command-handler.js.map +1 -1
- package/dist/core/hooks-loader.d.ts +74 -0
- package/dist/core/hooks-loader.d.ts.map +1 -0
- package/dist/core/hooks-loader.js +302 -0
- package/dist/core/hooks-loader.js.map +1 -0
- package/dist/core/session-manager.d.ts +14 -0
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +107 -2
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/stream-formatter.d.ts +1 -1
- package/dist/core/stream-formatter.d.ts.map +1 -1
- package/dist/core/stream-formatter.js +12 -3
- package/dist/core/stream-formatter.js.map +1 -1
- package/dist/index.js +169 -19
- package/dist/index.js.map +1 -1
- package/dist/state/store.d.ts +1 -0
- package/dist/state/store.d.ts.map +1 -1
- package/dist/state/store.js +24 -2
- package/dist/state/store.js.map +1 -1
- package/dist/types.d.ts +3 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -6,12 +6,11 @@ import { formatEvent, formatPermissionRequest, formatUserInputRequest } from './
|
|
|
6
6
|
import { WorkspaceWatcher, initWorkspace, getWorkspacePath } from './core/workspace-manager.js';
|
|
7
7
|
import { MattermostAdapter } from './channels/mattermost/adapter.js';
|
|
8
8
|
import { StreamingHandler } from './channels/mattermost/streaming.js';
|
|
9
|
-
import { getChannelPrefs, getAllChannelSessions, closeDb, listPermissionRulesForScope, removePermissionRule, clearPermissionRules } from './state/store.js';
|
|
9
|
+
import { getChannelPrefs, setChannelPrefs, getAllChannelSessions, closeDb, listPermissionRulesForScope, removePermissionRule, clearPermissionRules, getTaskHistory } from './state/store.js';
|
|
10
10
|
import { extractThreadRequest, resolveThreadRoot } from './core/thread-utils.js';
|
|
11
11
|
import { initScheduler, stopAll as stopScheduler, listJobs, removeJob, pauseJob, resumeJob, formatInTimezone, describeCron } from './core/scheduler.js';
|
|
12
12
|
import { markBusy, markIdle, markIdleImmediate, isBusy, waitForChannelIdle, cancelIdleDebounce } from './core/channel-idle.js';
|
|
13
13
|
import { LoopDetector, MAX_IDENTICAL_CALLS } from './core/loop-detector.js';
|
|
14
|
-
import { getTaskHistory } from './state/store.js';
|
|
15
14
|
import { checkUserAccess } from './core/access-control.js';
|
|
16
15
|
import { createLogger, setLogLevel } from './logger.js';
|
|
17
16
|
import fs from 'node:fs';
|
|
@@ -19,6 +18,9 @@ import path from 'node:path';
|
|
|
19
18
|
const log = createLogger('bridge');
|
|
20
19
|
// Active streaming responses, keyed by channelId
|
|
21
20
|
const activeStreams = new Map(); // channelId → streamKey
|
|
21
|
+
// Preserve thread context across turn_end stream finalization so auto-started
|
|
22
|
+
// streams stay in the same thread.
|
|
23
|
+
const channelThreadRoots = new Map(); // channelId → threadRootId
|
|
22
24
|
// Track channels where the initial "Working..." has been posted (reset on new user message)
|
|
23
25
|
const initialStreamPosted = new Set();
|
|
24
26
|
// Activity feed: a single edit-in-place message accumulating tool call lines per channel
|
|
@@ -558,11 +560,21 @@ async function handleMidTurnMessage(msg, sessionManager, platformName, botName)
|
|
|
558
560
|
return;
|
|
559
561
|
}
|
|
560
562
|
if (lower === '/remember' || lower === '/always approve') {
|
|
561
|
-
sessionManager.
|
|
563
|
+
if (sessionManager.isHookPermission(msg.channelId)) {
|
|
564
|
+
sessionManager.resolvePermission(msg.channelId, true);
|
|
565
|
+
}
|
|
566
|
+
else {
|
|
567
|
+
sessionManager.resolvePermission(msg.channelId, true, true);
|
|
568
|
+
}
|
|
562
569
|
return;
|
|
563
570
|
}
|
|
564
571
|
if (lower === '/always deny') {
|
|
565
|
-
sessionManager.
|
|
572
|
+
if (sessionManager.isHookPermission(msg.channelId)) {
|
|
573
|
+
sessionManager.resolvePermission(msg.channelId, false);
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
sessionManager.resolvePermission(msg.channelId, false, true);
|
|
577
|
+
}
|
|
566
578
|
return;
|
|
567
579
|
}
|
|
568
580
|
// Unrecognized text or slash commands — auto-deny the permission and
|
|
@@ -589,6 +601,7 @@ async function handleMidTurnMessage(msg, sessionManager, platformName, botName)
|
|
|
589
601
|
await resolved.streaming.cancelStream(stopStreamKey);
|
|
590
602
|
activeStreams.delete(msg.channelId);
|
|
591
603
|
}
|
|
604
|
+
channelThreadRoots.delete(msg.channelId);
|
|
592
605
|
await finalizeActivityFeed(msg.channelId, adapter);
|
|
593
606
|
await sessionManager.abortSession(msg.channelId);
|
|
594
607
|
markIdleImmediate(msg.channelId);
|
|
@@ -601,6 +614,7 @@ async function handleMidTurnMessage(msg, sessionManager, platformName, botName)
|
|
|
601
614
|
await resolved.streaming.cancelStream(oldStreamKey);
|
|
602
615
|
activeStreams.delete(msg.channelId);
|
|
603
616
|
}
|
|
617
|
+
channelThreadRoots.delete(msg.channelId);
|
|
604
618
|
await finalizeActivityFeed(msg.channelId, adapter);
|
|
605
619
|
loopDetector.reset(msg.channelId);
|
|
606
620
|
await sessionManager.newSession(msg.channelId);
|
|
@@ -773,6 +787,7 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
|
|
|
773
787
|
await streaming.cancelStream(oldStreamKey);
|
|
774
788
|
activeStreams.delete(msg.channelId);
|
|
775
789
|
}
|
|
790
|
+
channelThreadRoots.delete(msg.channelId);
|
|
776
791
|
await finalizeActivityFeed(msg.channelId, adapter);
|
|
777
792
|
loopDetector.reset(msg.channelId);
|
|
778
793
|
await sessionManager.newSession(msg.channelId);
|
|
@@ -786,6 +801,7 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
|
|
|
786
801
|
await streaming.cancelStream(stopStreamKey);
|
|
787
802
|
activeStreams.delete(msg.channelId);
|
|
788
803
|
}
|
|
804
|
+
channelThreadRoots.delete(msg.channelId);
|
|
789
805
|
await finalizeActivityFeed(msg.channelId, adapter);
|
|
790
806
|
await sessionManager.abortSession(msg.channelId);
|
|
791
807
|
await adapter.sendMessage(msg.channelId, '🛑 Task stopped.', { threadRootId: threadRoot });
|
|
@@ -1096,15 +1112,28 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
|
|
|
1096
1112
|
case 'skills': {
|
|
1097
1113
|
const skills = sessionManager.getSkillInfo(msg.channelId);
|
|
1098
1114
|
const mcpInfo = sessionManager.getMcpServerInfo(msg.channelId);
|
|
1115
|
+
const hooksInfo = sessionManager.getHooksInfo(msg.channelId);
|
|
1099
1116
|
const lines = ['🧰 **Skills & Tools**', ''];
|
|
1100
1117
|
if (skills.length > 0) {
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1118
|
+
const active = skills.filter(s => !s.disabled);
|
|
1119
|
+
const disabled = skills.filter(s => s.disabled);
|
|
1120
|
+
if (active.length > 0) {
|
|
1121
|
+
lines.push('🟢 **Active Skills**');
|
|
1122
|
+
for (const s of active) {
|
|
1123
|
+
const desc = s.description ? ` — ${s.description}` : '';
|
|
1124
|
+
const flag = s.pending ? ' ⏳ _reload to activate_' : '';
|
|
1125
|
+
lines.push(`• \`${s.name}\`${desc} _(${s.source})_${flag}`);
|
|
1126
|
+
}
|
|
1127
|
+
lines.push('');
|
|
1128
|
+
}
|
|
1129
|
+
if (disabled.length > 0) {
|
|
1130
|
+
lines.push('🔴 **Disabled Skills**');
|
|
1131
|
+
for (const s of disabled) {
|
|
1132
|
+
const desc = s.description ? ` — ${s.description}` : '';
|
|
1133
|
+
lines.push(`• \`${s.name}\`${desc} _(${s.source})_`);
|
|
1134
|
+
}
|
|
1135
|
+
lines.push('');
|
|
1106
1136
|
}
|
|
1107
|
-
lines.push('');
|
|
1108
1137
|
}
|
|
1109
1138
|
if (mcpInfo.length > 0) {
|
|
1110
1139
|
lines.push('**MCP Servers**');
|
|
@@ -1114,6 +1143,14 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
|
|
|
1114
1143
|
}
|
|
1115
1144
|
lines.push('');
|
|
1116
1145
|
}
|
|
1146
|
+
if (hooksInfo.length > 0) {
|
|
1147
|
+
lines.push('**Hooks**');
|
|
1148
|
+
for (const h of hooksInfo) {
|
|
1149
|
+
const count = h.commandCount > 1 ? ` (${h.commandCount} commands)` : '';
|
|
1150
|
+
lines.push(`• \`${h.hookType}\`${count} _(${h.source})_`);
|
|
1151
|
+
}
|
|
1152
|
+
lines.push('');
|
|
1153
|
+
}
|
|
1117
1154
|
// Fetch built-in tools from SDK
|
|
1118
1155
|
const sdkTools = await sessionManager.listSessionTools(msg.channelId);
|
|
1119
1156
|
if (sdkTools.length > 0) {
|
|
@@ -1130,6 +1167,79 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
|
|
|
1130
1167
|
await adapter.sendMessage(msg.channelId, lines.join('\n'), { threadRootId: threadRoot });
|
|
1131
1168
|
break;
|
|
1132
1169
|
}
|
|
1170
|
+
case 'skill_toggle': {
|
|
1171
|
+
const { action: toggleAction, targets } = cmdResult.payload;
|
|
1172
|
+
const skills = sessionManager.getSkillInfo(msg.channelId);
|
|
1173
|
+
const prefs = getChannelPrefs(msg.channelId);
|
|
1174
|
+
const currentDisabled = new Set(prefs?.disabledSkills ?? []);
|
|
1175
|
+
// Handle "all" keyword (only valid as sole target)
|
|
1176
|
+
if (targets.some(t => t.toLowerCase() === 'all')) {
|
|
1177
|
+
if (targets.length > 1) {
|
|
1178
|
+
await adapter.sendMessage(msg.channelId, '⚠️ `all` cannot be combined with other skill names.', { threadRootId: threadRoot });
|
|
1179
|
+
break;
|
|
1180
|
+
}
|
|
1181
|
+
if (toggleAction === 'disable') {
|
|
1182
|
+
const allNames = [...new Set(skills.map(s => s.name))];
|
|
1183
|
+
setChannelPrefs(msg.channelId, { disabledSkills: allNames });
|
|
1184
|
+
await adapter.sendMessage(msg.channelId, `🔴 Disabled all ${allNames.length} skills. Use \`/reload\` to apply.`, { threadRootId: threadRoot });
|
|
1185
|
+
}
|
|
1186
|
+
else {
|
|
1187
|
+
setChannelPrefs(msg.channelId, { disabledSkills: [] });
|
|
1188
|
+
await adapter.sendMessage(msg.channelId, `🟢 Enabled all skills. Use \`/reload\` to apply.`, { threadRootId: threadRoot });
|
|
1189
|
+
}
|
|
1190
|
+
break;
|
|
1191
|
+
}
|
|
1192
|
+
const matched = [];
|
|
1193
|
+
const notFound = [];
|
|
1194
|
+
const ambiguous = [];
|
|
1195
|
+
for (const target of targets) {
|
|
1196
|
+
const lower = target.toLowerCase();
|
|
1197
|
+
const exact = skills.find(s => s.name.toLowerCase() === lower);
|
|
1198
|
+
if (exact) {
|
|
1199
|
+
if (toggleAction === 'disable')
|
|
1200
|
+
currentDisabled.add(exact.name);
|
|
1201
|
+
else
|
|
1202
|
+
currentDisabled.delete(exact.name);
|
|
1203
|
+
matched.push(exact.name);
|
|
1204
|
+
continue;
|
|
1205
|
+
}
|
|
1206
|
+
const substringMatches = skills.filter(s => s.name.toLowerCase().includes(lower));
|
|
1207
|
+
if (substringMatches.length === 1) {
|
|
1208
|
+
const skill = substringMatches[0];
|
|
1209
|
+
if (toggleAction === 'disable')
|
|
1210
|
+
currentDisabled.add(skill.name);
|
|
1211
|
+
else
|
|
1212
|
+
currentDisabled.delete(skill.name);
|
|
1213
|
+
matched.push(skill.name);
|
|
1214
|
+
}
|
|
1215
|
+
else if (substringMatches.length > 1) {
|
|
1216
|
+
ambiguous.push(`"${target}" matches: ${substringMatches.map(s => `\`${s.name}\``).join(', ')}`);
|
|
1217
|
+
}
|
|
1218
|
+
else {
|
|
1219
|
+
notFound.push(target);
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
if (matched.length > 0) {
|
|
1223
|
+
setChannelPrefs(msg.channelId, { disabledSkills: [...currentDisabled] });
|
|
1224
|
+
}
|
|
1225
|
+
const lines = [];
|
|
1226
|
+
if (matched.length > 0) {
|
|
1227
|
+
const verb = toggleAction === 'disable' ? '🔴 Disabled' : '🟢 Enabled';
|
|
1228
|
+
const names = matched.map(n => `\`${n}\``).join(', ');
|
|
1229
|
+
lines.push(`${verb} ${names}.`);
|
|
1230
|
+
}
|
|
1231
|
+
if (ambiguous.length > 0) {
|
|
1232
|
+
lines.push(`⚠️ Ambiguous: ${ambiguous.join('; ')}`);
|
|
1233
|
+
}
|
|
1234
|
+
if (notFound.length > 0) {
|
|
1235
|
+
lines.push(`❌ No match: ${notFound.map(n => `"${n}"`).join(', ')}`);
|
|
1236
|
+
}
|
|
1237
|
+
if (matched.length > 0) {
|
|
1238
|
+
lines.push('Use `/reload` to apply.');
|
|
1239
|
+
}
|
|
1240
|
+
await adapter.sendMessage(msg.channelId, lines.join(' '), { threadRootId: threadRoot });
|
|
1241
|
+
break;
|
|
1242
|
+
}
|
|
1133
1243
|
case 'mcp': {
|
|
1134
1244
|
const mcpInfo = sessionManager.getMcpServerInfo(msg.channelId);
|
|
1135
1245
|
if (mcpInfo.length === 0) {
|
|
@@ -1353,13 +1463,15 @@ async function handleReaction(reaction, sessionManager, platformName, botName) {
|
|
|
1353
1463
|
}
|
|
1354
1464
|
}
|
|
1355
1465
|
else if (reaction.emoji === 'floppy_disk') {
|
|
1356
|
-
|
|
1357
|
-
|
|
1466
|
+
const isHook = sessionManager.isHookPermission(reaction.channelId);
|
|
1467
|
+
if (sessionManager.resolvePermission(reaction.channelId, true, !isHook)) {
|
|
1468
|
+
await adapter.sendMessage(reaction.channelId, isHook ? '✅ Approved via reaction.' : '💾 Approved + remembered via reaction.');
|
|
1358
1469
|
}
|
|
1359
1470
|
}
|
|
1360
1471
|
else if (reaction.emoji === 'no_entry_sign') {
|
|
1361
|
-
|
|
1362
|
-
|
|
1472
|
+
const isHook = sessionManager.isHookPermission(reaction.channelId);
|
|
1473
|
+
if (sessionManager.resolvePermission(reaction.channelId, false, !isHook)) {
|
|
1474
|
+
await adapter.sendMessage(reaction.channelId, isHook ? '❌ Denied via reaction.' : '🚫 Denied + remembered via reaction.');
|
|
1363
1475
|
}
|
|
1364
1476
|
}
|
|
1365
1477
|
}
|
|
@@ -1398,19 +1510,23 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
|
|
|
1398
1510
|
if (event.type === 'bridge.permission_request') {
|
|
1399
1511
|
const streamKey = activeStreams.get(channelId);
|
|
1400
1512
|
const threadRootId = streamKey ? streaming.getStreamThreadRootId(streamKey) : undefined;
|
|
1513
|
+
if (threadRootId)
|
|
1514
|
+
channelThreadRoots.set(channelId, threadRootId);
|
|
1401
1515
|
if (streamKey) {
|
|
1402
1516
|
await streaming.finalizeStream(streamKey);
|
|
1403
1517
|
activeStreams.delete(channelId);
|
|
1404
1518
|
}
|
|
1405
1519
|
await finalizeActivityFeed(channelId, adapter);
|
|
1406
|
-
const { toolName, serverName, input, commands } = event.data;
|
|
1407
|
-
const formatted = formatPermissionRequest(toolName, input, commands, serverName);
|
|
1520
|
+
const { toolName, serverName, input, commands, hookReason, fromHook } = event.data;
|
|
1521
|
+
const formatted = formatPermissionRequest(toolName, input, commands, serverName, hookReason, fromHook);
|
|
1408
1522
|
await adapter.sendMessage(channelId, formatted, { threadRootId });
|
|
1409
1523
|
return;
|
|
1410
1524
|
}
|
|
1411
1525
|
if (event.type === 'bridge.user_input_request') {
|
|
1412
1526
|
const streamKey = activeStreams.get(channelId);
|
|
1413
1527
|
const threadRootId = streamKey ? streaming.getStreamThreadRootId(streamKey) : undefined;
|
|
1528
|
+
if (threadRootId)
|
|
1529
|
+
channelThreadRoots.set(channelId, threadRootId);
|
|
1414
1530
|
if (streamKey) {
|
|
1415
1531
|
await streaming.finalizeStream(streamKey);
|
|
1416
1532
|
activeStreams.delete(channelId);
|
|
@@ -1479,7 +1595,8 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
|
|
|
1479
1595
|
const initialContent = event.type === 'assistant.message'
|
|
1480
1596
|
? formatted.content
|
|
1481
1597
|
: (formatted.content || undefined);
|
|
1482
|
-
const
|
|
1598
|
+
const savedThreadRoot = channelThreadRoots.get(channelId);
|
|
1599
|
+
const newKey = await streaming.startStream(channelId, savedThreadRoot, initialContent);
|
|
1483
1600
|
activeStreams.set(channelId, newKey);
|
|
1484
1601
|
}
|
|
1485
1602
|
else {
|
|
@@ -1532,6 +1649,7 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
|
|
|
1532
1649
|
case 'error':
|
|
1533
1650
|
markIdleImmediate(channelId);
|
|
1534
1651
|
nudgePending.delete(channelId);
|
|
1652
|
+
channelThreadRoots.delete(channelId);
|
|
1535
1653
|
if (streamKey) {
|
|
1536
1654
|
await streaming.cancelStream(streamKey, formatted.content);
|
|
1537
1655
|
activeStreams.delete(channelId);
|
|
@@ -1541,6 +1659,21 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
|
|
|
1541
1659
|
}
|
|
1542
1660
|
break;
|
|
1543
1661
|
case 'status':
|
|
1662
|
+
// Finalize active stream on turn_start if it has content from a previous
|
|
1663
|
+
// turn or between-turn events (e.g., subagent results arriving after
|
|
1664
|
+
// turn_end). This complements turn_end finalization by catching content
|
|
1665
|
+
// that arrives outside turn boundaries.
|
|
1666
|
+
if (event.type === 'assistant.turn_start' && streamKey && streaming.hasContent(streamKey)) {
|
|
1667
|
+
const threadRootId = streaming.getStreamThreadRootId(streamKey);
|
|
1668
|
+
if (threadRootId) {
|
|
1669
|
+
channelThreadRoots.set(channelId, threadRootId);
|
|
1670
|
+
}
|
|
1671
|
+
else {
|
|
1672
|
+
channelThreadRoots.delete(channelId);
|
|
1673
|
+
}
|
|
1674
|
+
await streaming.finalizeStream(streamKey);
|
|
1675
|
+
activeStreams.delete(channelId);
|
|
1676
|
+
}
|
|
1544
1677
|
// Send subagent status messages to chat
|
|
1545
1678
|
if (formatted.content) {
|
|
1546
1679
|
if (streamKey) {
|
|
@@ -1549,14 +1682,31 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
|
|
|
1549
1682
|
}
|
|
1550
1683
|
await adapter.sendMessage(channelId, formatted.content);
|
|
1551
1684
|
}
|
|
1685
|
+
// Finalize stream on turn_end if it has content — preserves multi-turn
|
|
1686
|
+
// messages so each turn's text gets its own chat message instead of being
|
|
1687
|
+
// overwritten by the next turn's replaceContent().
|
|
1688
|
+
// Only finalize when the stream has real content to avoid "Working..." churn.
|
|
1689
|
+
if (event.type === 'assistant.turn_end') {
|
|
1690
|
+
if (streamKey && streaming.hasContent(streamKey)) {
|
|
1691
|
+
// Preserve thread context for the next auto-started stream
|
|
1692
|
+
const threadRootId = streaming.getStreamThreadRootId(streamKey);
|
|
1693
|
+
if (threadRootId) {
|
|
1694
|
+
channelThreadRoots.set(channelId, threadRootId);
|
|
1695
|
+
}
|
|
1696
|
+
else {
|
|
1697
|
+
channelThreadRoots.delete(channelId);
|
|
1698
|
+
}
|
|
1699
|
+
await streaming.finalizeStream(streamKey);
|
|
1700
|
+
activeStreams.delete(channelId);
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1552
1703
|
// Finalize stream when the session goes idle (all turns complete).
|
|
1553
|
-
// turn_end fires between tool cycles — DON'T finalize there or we get
|
|
1554
|
-
// duplicate "Working..." messages from auto-starting new streams.
|
|
1555
1704
|
if (event.type === 'session.idle') {
|
|
1556
1705
|
markIdle(channelId);
|
|
1557
1706
|
nudgePending.delete(channelId);
|
|
1558
1707
|
await finalizeActivityFeed(channelId, adapter);
|
|
1559
1708
|
initialStreamPosted.delete(channelId);
|
|
1709
|
+
channelThreadRoots.delete(channelId);
|
|
1560
1710
|
if (streamKey) {
|
|
1561
1711
|
log.info(`Session idle, finalizing stream for ${channelId.slice(0, 8)}...`);
|
|
1562
1712
|
await streaming.finalizeStream(streamKey);
|