polygram 0.8.0-rc.46 → 0.8.0-rc.48

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "polygram",
4
- "version": "0.8.0-rc.46",
4
+ "version": "0.8.0-rc.48",
5
5
  "description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Multi-bot, multi-chat, per-topic isolation; SQLite transcripts; inline-keyboard approvals. Bundles /polygram:status|logs|pair-code|approvals admin commands and a history skill.",
6
6
  "keywords": [
7
7
  "telegram",
@@ -98,9 +98,17 @@
98
98
  "cwd": "/Users/you/admin-agent",
99
99
  "requireMention": true,
100
100
  "isolateTopics": true,
101
+ "_comment_topics": "rc.48: each topic entry is EITHER a string (legacy: just a label) OR an object with optional fields {name, agent, cwd, model, effort, permissionMode}. Object form lets a topic override chat-level config. Per-topic permissionMode overrides chat-level — typical use: scope one topic to permissionMode:'default' (so settings.json gates apply) while the rest of the chat stays on bypassPermissions. Object form requires isolateTopics: true (each topic gets its own SDK Query); polygram emits a startup warning otherwise.",
101
102
  "topics": {
102
103
  "100": "Customer A",
103
- "200": "Customer B"
104
+ "200": {
105
+ "name": "Customer B",
106
+ "agent": "customer-b-helper",
107
+ "cwd": "/Users/you/customer-b-projects",
108
+ "model": "opus",
109
+ "effort": "high",
110
+ "permissionMode": "default"
111
+ }
104
112
  }
105
113
  },
106
114
 
@@ -283,15 +283,27 @@ function loadAgent(agentName, { homeDir = process.env.HOME, cwd = null, logger =
283
283
 
284
284
  /**
285
285
  * Compose a chat's final SdkOptions from defaults + agent + per-chat
286
- * overrides. Precedence: chatConfig > agent > defaults.
286
+ * overrides + per-topic overrides. Precedence (highest to lowest):
287
+ * topicConfig > chatConfig > agent.raw > defaults
288
+ *
289
+ * rc.48 added the per-topic layer (`topicConfig` arg). Per-topic
290
+ * overrides are the principal rc.48 use case — typically loosening
291
+ * an agent's `bypassPermissions` default to `default` for a sensitive
292
+ * topic (so canUseTool prompts fire there) while keeping the rest of
293
+ * the chat in bypass mode. Per-topic permissionMode MUST override the
294
+ * chat-level one for this to work.
287
295
  *
288
296
  * @param {object} chatConfig — config.chats[chatId].
289
297
  * @param {AgentBundle|null} agentBundle — null if chat has no agent.
290
298
  * @param {object} defaults — config.defaults.
299
+ * @param {object} [topicConfig] — per-topic overrides from
300
+ * getTopicConfig(chatConfig, threadId). Empty object when there's
301
+ * no active topic, no override config, or topic uses legacy string
302
+ * form. Highest precedence — overrides chatConfig.
291
303
  *
292
304
  * @returns {object} SdkOptions for `query({ options: ... })`.
293
305
  */
294
- function composeSdkOptions(chatConfig = {}, agentBundle = null, defaults = {}) {
306
+ function composeSdkOptions(chatConfig = {}, agentBundle = null, defaults = {}, topicConfig = {}) {
295
307
  // Start with defaults — these are the lowest-priority.
296
308
  const opts = { ...defaults };
297
309
 
@@ -302,16 +314,18 @@ function composeSdkOptions(chatConfig = {}, agentBundle = null, defaults = {}) {
302
314
  if (agentBundle.mcpServers && Object.keys(agentBundle.mcpServers).length) {
303
315
  opts.mcpServers = { ...(opts.mcpServers || {}), ...agentBundle.mcpServers };
304
316
  }
305
- // Agent-level model/effort/etc — only if chatConfig doesn't
306
- // override.
317
+ // Agent-level model/effort/etc — only if chatConfig AND
318
+ // topicConfig don't override.
307
319
  for (const key of ['model', 'effort', 'thinking', 'permissionMode']) {
308
- if (agentBundle.raw?.[key] != null && chatConfig[key] == null) {
320
+ if (agentBundle.raw?.[key] != null
321
+ && chatConfig[key] == null
322
+ && topicConfig?.[key] == null) {
309
323
  opts[key] = agentBundle.raw[key];
310
324
  }
311
325
  }
312
326
  }
313
327
 
314
- // Chat-level overrides (highest priority).
328
+ // Chat-level overrides.
315
329
  for (const [k, v] of Object.entries(chatConfig)) {
316
330
  if (v == null) continue;
317
331
  // Don't override the spread system-prompt with `agent` config
@@ -320,6 +334,18 @@ function composeSdkOptions(chatConfig = {}, agentBundle = null, defaults = {}) {
320
334
  opts[k] = v;
321
335
  }
322
336
 
337
+ // rc.48: per-topic overrides (highest priority). Same `agent` exclusion
338
+ // — `agent` here is a polygram name reference, NOT an SdkOptions
339
+ // field. polygram's spawn flow resolves topicConfig.agent into the
340
+ // correct agentBundle BEFORE calling composeSdkOptions, so by the
341
+ // time we get here, agentBundle already reflects the topic's agent
342
+ // choice and the `agent` string itself shouldn't leak into opts.
343
+ for (const [k, v] of Object.entries(topicConfig || {})) {
344
+ if (v == null) continue;
345
+ if (k === 'agent') continue;
346
+ opts[k] = v;
347
+ }
348
+
323
349
  return opts;
324
350
  }
325
351
 
@@ -193,6 +193,17 @@ class ProcessManagerSdk {
193
193
  onStreamChunk = null,
194
194
  onToolUse = null,
195
195
  onAssistantMessageStart = null,
196
+ // rc.47: fires when an SDK assistant message arrives with NO head
197
+ // pending in entry.pendingQueue — i.e. an autonomous turn (typical
198
+ // ScheduleWakeup case where the agent self-fires without a
199
+ // corresponding pm.send). Polygram wires this to a Telegram-send
200
+ // function that derives chat_id (always) and thread_id (when
201
+ // isolateTopics) from the sessionKey via getChatIdFromKey /
202
+ // getThreadIdFromKey, then forwards the text to the right chat/
203
+ // topic. Pre-rc.47 these messages were silently dropped at the
204
+ // `&& head` gate in _handleEvent. Subagent messages
205
+ // (parent_tool_use_id != null) are still filtered upstream.
206
+ onAutonomousAssistantMessage = null,
196
207
  onCompactBoundary = null,
197
208
  onQueueDrop = null,
198
209
  onThinking = null,
@@ -211,6 +222,7 @@ class ProcessManagerSdk {
211
222
  this.onStreamChunk = onStreamChunk;
212
223
  this.onToolUse = onToolUse;
213
224
  this.onAssistantMessageStart = onAssistantMessageStart;
225
+ this.onAutonomousAssistantMessage = onAutonomousAssistantMessage;
214
226
  this.onCompactBoundary = onCompactBoundary;
215
227
  this.onQueueDrop = onQueueDrop;
216
228
  this.onThinking = onThinking;
@@ -425,6 +437,23 @@ class ProcessManagerSdk {
425
437
  return;
426
438
  }
427
439
 
440
+ if (msg.type === 'assistant' && !head) {
441
+ // rc.47: autonomous assistant message — no user-initiated
442
+ // pm.send is in flight. Typical cause: ScheduleWakeup fired,
443
+ // the agent emitted a self-driven response. Pre-rc.47 these
444
+ // were silently dropped by the `&& head` gate. Now we route
445
+ // them via onAutonomousAssistantMessage so polygram can
446
+ // forward the text to the right Telegram chat/topic.
447
+ if (msg.parent_tool_use_id != null) return;
448
+ const text = extractAssistantText(msg);
449
+ if (!text) return;
450
+ if (this.onAutonomousAssistantMessage) {
451
+ try { this.onAutonomousAssistantMessage(entry.sessionKey, msg, entry); }
452
+ catch (err) { this.logger.error?.(`[${entry.label}] onAutonomousAssistantMessage: ${err.message}`); }
453
+ }
454
+ return;
455
+ }
456
+
428
457
  if (msg.type === 'assistant' && head) {
429
458
  // Subagent filter (Phase 1 step 7): top-level only.
430
459
  if (msg.parent_tool_use_id != null) return;
@@ -16,8 +16,25 @@
16
16
  * topic's conversation can't bleed into Billing topic's memory. This
17
17
  * matches OpenClaw's model and is the right call when topics represent
18
18
  * genuinely separate projects.
19
+ *
20
+ * rc.48: per-topic config overrides. `topics[threadId]` can be either a
21
+ * string (legacy: just a label) or an object with optional fields:
22
+ * { name, agent, cwd, model, effort, permissionMode }
23
+ * Object form lets a topic override chat-level config — typically used
24
+ * to scope a single topic to a different agent (e.g. music-curation),
25
+ * a different working dir, or to switch from `bypassPermissions` to
26
+ * `default` so canUseTool prompts fire for sensitive operations in
27
+ * that topic only. See getTopicConfig.
28
+ *
29
+ * Per-topic overrides only take effect when isolateTopics: true (each
30
+ * topic has its own SDK Query). With isolateTopics: false all topics
31
+ * share one Query; the Query's options are fixed at first-spawn time.
32
+ * polygram emits a one-time startup warning if topic overrides are
33
+ * configured on a non-isolating chat.
19
34
  */
20
35
 
36
+ 'use strict';
37
+
21
38
  function getSessionKey(chatId, threadId, chatConfig) {
22
39
  const isolate = chatConfig?.isolateTopics === true;
23
40
  if (threadId && isolate) return `${chatId}:${threadId}`;
@@ -28,4 +45,71 @@ function getChatIdFromKey(sessionKey) {
28
45
  return sessionKey.split(':')[0];
29
46
  }
30
47
 
31
- module.exports = { getSessionKey, getChatIdFromKey };
48
+ /**
49
+ * Inverse of `getChatIdFromKey`: returns the thread_id portion of an
50
+ * isolated-topic sessionKey, or null when there's no thread suffix.
51
+ * Used by rc.47 autonomous-wakeup routing — when ScheduleWakeup
52
+ * fires inside a polygram-spawned Query without a corresponding
53
+ * pm.send, we derive (chat_id, thread_id) from sessionKey to route
54
+ * the autonomous output back to the right Telegram chat/topic.
55
+ */
56
+ function getThreadIdFromKey(sessionKey) {
57
+ if (typeof sessionKey !== 'string' || !sessionKey) return null;
58
+ const idx = sessionKey.indexOf(':');
59
+ if (idx < 0) return null;
60
+ const thread = sessionKey.slice(idx + 1);
61
+ return thread || null;
62
+ }
63
+
64
+ /**
65
+ * Resolve the human-readable name for a topic. Handles both the legacy
66
+ * string form (`topics["100"] = "Orders"`) and the rc.48 object form
67
+ * (`topics["200"] = { name: "Music", agent: "music-curation" }`). Falls
68
+ * back to the threadId itself if the topic has no name field.
69
+ */
70
+ function getTopicName(chatConfig, threadId) {
71
+ if (threadId == null || threadId === '') return null;
72
+ const t = chatConfig?.topics?.[threadId];
73
+ if (typeof t === 'string') return t;
74
+ if (t && typeof t === 'object' && typeof t.name === 'string' && t.name) {
75
+ return t.name;
76
+ }
77
+ return String(threadId);
78
+ }
79
+
80
+ /**
81
+ * rc.48: extract per-topic SdkOptions overrides. Returns the topic
82
+ * entry's overridable fields (model, effort, cwd, agent,
83
+ * permissionMode), excluding `name` (which is a polygram label, not
84
+ * an SdkOptions field — see getTopicName).
85
+ *
86
+ * Returns `{}` for:
87
+ * - missing threadId
88
+ * - missing chatConfig.topics
89
+ * - threadId not present in topics
90
+ * - legacy string topic entry (label-only, no overrides)
91
+ * - object with only `name`
92
+ *
93
+ * Callers (composeSdkOptions, polygram's spawn flow) merge this on
94
+ * top of chat-level config. Per-topic overrides take HIGHEST
95
+ * precedence — including overriding the chat-level permissionMode,
96
+ * which is the principal rc.48 use case (loosen one topic from the
97
+ * agent's `bypassPermissions` default to `default`).
98
+ */
99
+ function getTopicConfig(chatConfig, threadId) {
100
+ if (threadId == null || threadId === '') return {};
101
+ const t = chatConfig?.topics?.[threadId];
102
+ if (!t || typeof t !== 'object') return {};
103
+ // Strip `name` — that's the label, not an override. Everything else
104
+ // in the entry is an override candidate.
105
+ const { name, ...overrides } = t;
106
+ return overrides;
107
+ }
108
+
109
+ module.exports = {
110
+ getSessionKey,
111
+ getChatIdFromKey,
112
+ getThreadIdFromKey,
113
+ getTopicName,
114
+ getTopicConfig,
115
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.8.0-rc.46",
3
+ "version": "0.8.0-rc.48",
4
4
  "description": "Telegram daemon for Claude Code that preserves the OpenClaw per-chat session model. Migration path for OpenClaw users moving to Claude Code.",
5
5
  "main": "lib/ipc-client.js",
6
6
  "bin": {
package/polygram.js CHANGED
@@ -30,7 +30,7 @@ const { ProcessManager } = require('./lib/process-manager');
30
30
  // callbacks), so the rest of polygram.js doesn't branch beyond the
31
31
  // pick-at-startup. Phase 4 deletes the CLI version after Phase 5
32
32
  // soak proves SDK stable. See docs/0.8.0-architecture-decisions.md.
33
- const { ProcessManagerSdk } = require('./lib/process-manager-sdk');
33
+ const { ProcessManagerSdk, extractAssistantText } = require('./lib/process-manager-sdk');
34
34
  // rc.42: autosteer-buffer module deleted. Native SDK priority push
35
35
  // (pm.injectUserMessage) replaces the buffer + PostToolBatch detour.
36
36
  const { createAutosteeredRefs } = require('./lib/autosteered-refs');
@@ -195,12 +195,13 @@ function isWellFormedMessage(msg) {
195
195
  }
196
196
 
197
197
  // ─── Session key — moved to lib/session-key.js so tests can import it. ─
198
- const { getSessionKey, getChatIdFromKey } = require('./lib/session-key');
199
-
200
- function getTopicName(chatConfig, threadId) {
201
- if (!threadId) return null;
202
- return chatConfig.topics?.[threadId] || threadId;
203
- }
198
+ const {
199
+ getSessionKey,
200
+ getChatIdFromKey,
201
+ getThreadIdFromKey,
202
+ getTopicName,
203
+ getTopicConfig,
204
+ } = require('./lib/session-key');
204
205
 
205
206
  function getSessionLabel(chatConfig, threadId) {
206
207
  const topic = getTopicName(chatConfig, threadId);
@@ -826,30 +827,52 @@ function spawnClaude(sessionKey, ctx) {
826
827
  * mcpServers, optional resume sessionId for continuity.
827
828
  */
828
829
  function buildSdkOptions(sessionKey, ctx) {
829
- const { chatConfig, existingSessionId, label, chatId } = ctx;
830
+ const { chatConfig, existingSessionId, label, chatId, threadId } = ctx;
831
+
832
+ // rc.48: per-topic config overrides. When `topics[threadId]` is an
833
+ // object (not a legacy string label), these fields take precedence
834
+ // over chat-level config. Principal use case: scope a single topic
835
+ // to a different agent, cwd, or permissionMode (e.g. flip music
836
+ // topic from `bypassPermissions` to `default` so settings.json
837
+ // permissions.allow gates apply).
838
+ //
839
+ // Per-topic overrides only apply meaningfully when isolateTopics is
840
+ // true — each topic has its own SDK Query. With isolateTopics: false
841
+ // all topics share one Query whose options are fixed at first-spawn.
842
+ // Startup validation (validateTopicConfigs) emits a one-time warning
843
+ // when a chat has topic overrides + isolateTopics: false.
844
+ const topicConfig = getTopicConfig(chatConfig, threadId);
845
+ const effectiveAgent = topicConfig.agent || chatConfig.agent;
830
846
 
831
847
  // Per-chat agent (D14): if pinned, load & compose. Failure is
832
848
  // non-fatal — chat falls back to defaults; logged for ops.
833
849
  let agentBundle = null;
834
- if (chatConfig.agent) {
850
+ if (effectiveAgent) {
835
851
  try {
836
- agentBundle = agentLoader.loadAgent(chatConfig.agent, {
852
+ agentBundle = agentLoader.loadAgent(effectiveAgent, {
837
853
  homeDir: CHILD_HOME,
838
854
  // Pass cwd so the loader checks Claude Code's project-level
839
855
  // path (`<cwd>/.claude/agents/<name>.md`) before the
840
- // user-level path or polygram's directory convention.
841
- cwd: chatConfig.cwd,
856
+ // user-level path or polygram's directory convention. rc.48:
857
+ // topic-level cwd takes precedence so topic-scoped agents
858
+ // load from the topic's project dir.
859
+ cwd: topicConfig.cwd || chatConfig.cwd,
842
860
  logger: console,
843
861
  });
844
862
  } catch (err) {
845
863
  console.error(`[${label}] agent-loader: ${err.message}`);
846
864
  logEvent('agent-load-failed', {
847
- chat_id: chatId, agent: chatConfig.agent, error: err.message,
865
+ chat_id: chatId, agent: effectiveAgent, error: err.message,
866
+ topic: threadId || null,
848
867
  });
849
868
  }
850
869
  }
851
870
 
852
- console.log(`[${label}] Spawning SDK Query (${chatConfig.model}/${chatConfig.effort})`);
871
+ const effectiveModel = topicConfig.model || chatConfig.model;
872
+ const effectiveEffort = topicConfig.effort || chatConfig.effort;
873
+ const agentSuffix = effectiveAgent && effectiveAgent !== chatConfig.agent
874
+ ? ` agent=${effectiveAgent}` : '';
875
+ console.log(`[${label}] Spawning SDK Query (${effectiveModel}/${effectiveEffort}${agentSuffix})`);
853
876
 
854
877
  // Env: SHADOW semantics (gate 33) — must enumerate every var
855
878
  // pollygram needs in the spawned worker.
@@ -916,10 +939,11 @@ function buildSdkOptions(sessionKey, ctx) {
916
939
  }),
917
940
  };
918
941
 
919
- // Compose with agent overlay + chat-level config. agent-loader
920
- // precedence: chatConfig > agent > defaults. The chatConfig keys
921
- // we care about for SDK options are model/effort/cwd/thinking;
922
- // others (agent, chrome, isolateTopics) are polygram-only.
942
+ // Compose with agent overlay + chat-level config + per-topic overrides.
943
+ // agent-loader precedence: topicConfig > chatConfig > agent > defaults.
944
+ // The chatConfig keys we care about for SDK options are
945
+ // model/effort/cwd/thinking; others (agent, chrome, isolateTopics)
946
+ // are polygram-only and stripped by composeSdkOptions.
923
947
  const composed = agentLoader.composeSdkOptions(
924
948
  {
925
949
  // chat-level overrides — only the keys SDK understands.
@@ -930,8 +954,19 @@ function buildSdkOptions(sessionKey, ctx) {
930
954
  },
931
955
  agentBundle,
932
956
  baseOpts,
957
+ topicConfig, // rc.48: highest precedence — overrides chat-level fields.
933
958
  );
934
959
 
960
+ // rc.48: keep permissionMode + allowDangerouslySkipPermissions
961
+ // consistent. baseOpts sets allowDangerouslySkipPermissions=true when
962
+ // permissionMode='bypassPermissions'. If a topic flipped permissionMode
963
+ // to anything else (typically 'default' to gate via settings.json),
964
+ // also disable allowDangerouslySkipPermissions so the SDK actually
965
+ // honours the permission gates instead of skipping them.
966
+ if (composed.permissionMode && composed.permissionMode !== 'bypassPermissions') {
967
+ composed.allowDangerouslySkipPermissions = false;
968
+ }
969
+
935
970
  // Append polygram's display constraints to the systemPrompt.
936
971
  // Infrastructure-layer hint — the agent's own prompt covers
937
972
  // business logic; polygram adds "your output renders in Telegram,
@@ -3665,6 +3700,50 @@ async function main() {
3665
3700
  const r = head?.context?.reactor;
3666
3701
  if (r && typeof r.heartbeat === 'function') r.heartbeat();
3667
3702
  },
3703
+ // rc.47: autonomous wakeup forwarding. Fires when an SDK
3704
+ // assistant message arrives with no head pending — typical
3705
+ // ScheduleWakeup case where the agent self-fires without an
3706
+ // inbound user message. We derive chat_id (always) and thread_id
3707
+ // (when isolateTopics) from the sessionKey, then send the text
3708
+ // to that chat/topic. Subagent messages were already filtered
3709
+ // upstream (parent_tool_use_id != null check in pm-sdk).
3710
+ //
3711
+ // Best-effort send: failures are logged but don't propagate —
3712
+ // an autonomous turn that can't be delivered shouldn't crash
3713
+ // the daemon. Telemetry emitted as `autonomous-wakeup-message`
3714
+ // so we can audit how often these fire and whether any get lost.
3715
+ onAutonomousAssistantMessage: (sessionKey, msg /* , entry */) => {
3716
+ try {
3717
+ const text = extractAssistantText(msg);
3718
+ if (!text) return;
3719
+ const chatId = getChatIdFromKey(sessionKey);
3720
+ const threadIdRaw = getThreadIdFromKey(sessionKey);
3721
+ const threadId = threadIdRaw ? parseInt(threadIdRaw, 10) : null;
3722
+ if (!bot) {
3723
+ console.error(`[${BOT_NAME}] autonomous wakeup: bot not ready, dropping ${text.length} chars`);
3724
+ return;
3725
+ }
3726
+ const params = {
3727
+ chat_id: chatId,
3728
+ text,
3729
+ ...(Number.isInteger(threadId) && { message_thread_id: threadId }),
3730
+ };
3731
+ // Don't `await` — keep the pm-sdk event loop unblocked. The
3732
+ // tg() wrapper handles its own retries / chunking.
3733
+ tg(bot, 'sendMessage', params,
3734
+ { source: 'autonomous-wakeup', botName: BOT_NAME }).catch((err) => {
3735
+ console.error(`[${BOT_NAME}] autonomous wakeup send failed: ${err.message}`);
3736
+ });
3737
+ logEvent('autonomous-wakeup-message', {
3738
+ chat_id: chatId,
3739
+ session_key: sessionKey,
3740
+ thread_id: threadIdRaw,
3741
+ text_len: text.length,
3742
+ });
3743
+ } catch (err) {
3744
+ console.error(`[${BOT_NAME}] autonomous wakeup handler: ${err.message}`);
3745
+ }
3746
+ },
3668
3747
  // rc.29 onThinking removed — replaced by simpler timer-based
3669
3748
  // approach in handleMessage (post-QUEUED setState). The
3670
3749
  // SDK-thinking-block detection added complexity (partial-message
@@ -3751,6 +3830,26 @@ async function main() {
3751
3830
  console.log(`polygram (LRU cap=${cap}, SQLite source of truth)`);
3752
3831
  console.log(`Chats: ${Object.entries(config.chats).map(([id, c]) => `${c.name} (${c.model}/${c.effort})`).join(', ')}`);
3753
3832
 
3833
+ // rc.48: validate per-topic config + isolateTopics relationship.
3834
+ // Per-topic SdkOptions overrides only take effect when each topic
3835
+ // gets its own SDK Query (isolateTopics: true). Without isolation
3836
+ // the Query is fixed at first-spawn time; subsequent topic-scoped
3837
+ // messages share that Query regardless of topic-level overrides.
3838
+ for (const [chatId, chatCfg] of Object.entries(config.chats)) {
3839
+ if (!chatCfg.topics || typeof chatCfg.topics !== 'object') continue;
3840
+ const overrideTopics = Object.entries(chatCfg.topics)
3841
+ .filter(([, t]) => t && typeof t === 'object'
3842
+ && Object.keys(t).some((k) => k !== 'name'));
3843
+ if (overrideTopics.length === 0) continue;
3844
+ if (chatCfg.isolateTopics !== true) {
3845
+ const ids = overrideTopics.map(([id]) => id).join(', ');
3846
+ console.warn(`[${BOT_NAME}] WARN: chat ${chatId} (${chatCfg.name}) has topic overrides on topic_ids=${ids} but isolateTopics is not true — overrides will be IGNORED. Set isolateTopics: true to make per-topic config take effect.`);
3847
+ logEvent('topic-override-without-isolation', {
3848
+ chat_id: chatId, name: chatCfg.name, topic_ids: ids,
3849
+ });
3850
+ }
3851
+ }
3852
+
3754
3853
  bot = createBot(config.bot.token);
3755
3854
 
3756
3855
  // Graceful shutdown: stop accepting new inbound, drain in-flight pendings