@blunking/codexlink 0.1.0 → 0.1.2

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,52 @@ function pendingReplyKey(entry) {
22
22
  return entry.turnId || `${entry.threadId || ""}:${entry.chatId}:${entry.messageId}`;
23
23
  }
24
24
 
25
+ function containsToken(text, token) {
26
+ const value = String(token || "").trim();
27
+ if (!value) {
28
+ return false;
29
+ }
30
+ const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
31
+ return new RegExp(`(^|[^a-z0-9_])${escaped}([^a-z0-9_]|$)`, "i").test(String(text || ""));
32
+ }
33
+
34
+ function looksLikeEscalation(text) {
35
+ const value = String(text || "");
36
+ return [
37
+ "eskalation",
38
+ "escalation",
39
+ "urgent",
40
+ "emergency",
41
+ "sofort",
42
+ "prio 0",
43
+ "p0",
44
+ "blocker"
45
+ ].some((token) => containsToken(value, token));
46
+ }
47
+
48
+ function classifyInboundRelevance(config, inbound) {
49
+ if (looksLikeEscalation(inbound.text)) {
50
+ return "escalation";
51
+ }
52
+
53
+ if (String(inbound.chatType || "") === "private") {
54
+ return "direct";
55
+ }
56
+
57
+ const text = String(inbound.text || "");
58
+ const agentName = String(config.agentName || "").trim();
59
+ if (agentName && agentName.toLowerCase() !== "default" && containsToken(text, agentName)) {
60
+ return "direct";
61
+ }
62
+
63
+ const lane = String(config.lane || "").trim();
64
+ if (lane && lane.toLowerCase() !== "general" && containsToken(text, lane)) {
65
+ return "lane";
66
+ }
67
+
68
+ return "ambient";
69
+ }
70
+
25
71
  function statusWeight(status) {
26
72
  switch (status) {
27
73
  case "delivered":
@@ -96,7 +142,7 @@ function mergeQueueEntry(current, incoming) {
96
142
  merged.deliveredAt = pickIsoLater(current.deliveredAt, incoming.deliveredAt);
97
143
  merged.ts = pickIsoLater(current.ts, incoming.ts);
98
144
 
99
- for (const field of ["threadId", "responsePreview", "stderr", "stdout", "chatType", "conversationKey", "groupTitle", "telegramThreadId", "senderIsBot"]) {
145
+ for (const field of ["threadId", "responsePreview", "stderr", "stdout", "chatType", "conversationKey", "groupTitle", "telegramThreadId", "senderIsBot", "relevance"]) {
100
146
  if (!merged[field]) {
101
147
  merged[field] = current[field] || incoming[field] || null;
102
148
  }
@@ -300,6 +346,7 @@ function normalizeInbound(message) {
300
346
  userId: message.from?.id ? String(message.from.id) : "",
301
347
  text,
302
348
  ts: nowIso(),
349
+ relevance: "ambient",
303
350
  status: "queued",
304
351
  attempts: 0,
305
352
  lastAttemptAt: null
@@ -310,6 +357,14 @@ function normalizeTelegramThreadId(value) {
310
357
  return String(value || "").trim();
311
358
  }
312
359
 
360
+ function isAllowedChat(config, chatId) {
361
+ const allowed = Array.isArray(config.allowedChatIds) ? config.allowedChatIds : [];
362
+ if (allowed.length === 0) {
363
+ return true;
364
+ }
365
+ return allowed.includes(String(chatId || "").trim());
366
+ }
367
+
313
368
  function splitTelegramText(text, maxLength = 3500) {
314
369
  const value = String(text || "").trim();
315
370
  if (!value) {
@@ -401,6 +456,28 @@ async function resolveThreadSessionPath(config, threadId) {
401
456
  return findRolloutFile(config.paths.sessionsDir, threadId) || "";
402
457
  }
403
458
 
459
+ function countOpenPendingReplies(state) {
460
+ return (state.pendingReplies || []).filter((entry) => !entry.sentAt && !["error", "ignored_bot", "superseded"].includes(String(entry.status || ""))).length;
461
+ }
462
+
463
+ async function resolveSessionActivity(config, threadId) {
464
+ const sessionPath = await resolveThreadSessionPath(config, threadId);
465
+ if (!sessionPath || !existsSync(sessionPath)) {
466
+ return {
467
+ sessionPath,
468
+ quietMs: Number.POSITIVE_INFINITY,
469
+ active: false
470
+ };
471
+ }
472
+
473
+ const quietMs = Math.max(0, Date.now() - statSync(sessionPath).mtimeMs);
474
+ return {
475
+ sessionPath,
476
+ quietMs,
477
+ active: quietMs < Number(config.idleCooldownMs || 0)
478
+ };
479
+ }
480
+
404
481
  function buildPendingReplyEntry(message, threadId, turnId, sessionPath, sessionOffset) {
405
482
  return {
406
483
  turnId: String(turnId || "").trim(),
@@ -526,7 +603,7 @@ function promoteVisibleQueuedEntry(config, state, threadId, message) {
526
603
  }
527
604
 
528
605
  async function resolveActiveThreadId(config, state, preferredThreadId) {
529
- const fallbackThreadId = String(preferredThreadId || state.currentThreadId || config.currentThreadId || "").trim();
606
+ const fallbackThreadId = String(preferredThreadId || config.currentThreadId || state.currentThreadId || "").trim();
530
607
  if (!config.appServerWsUrl) {
531
608
  return fallbackThreadId;
532
609
  }
@@ -584,12 +661,16 @@ export function bridgeStatus() {
584
661
  const state = loadState(config);
585
662
  const queued = state.queue.filter((item) => item.status === "queued");
586
663
  const submitted = state.queue.filter((item) => item.status === "submitted");
664
+ const ambient = queued.filter((item) => item.relevance === "ambient");
587
665
  const pendingReplies = (state.pendingReplies || []).filter((item) => !item.sentAt && item.status !== "error");
588
666
  return {
589
667
  agent: config.agentName,
590
668
  allowedChatId: config.allowedChatId || null,
591
- boundThreadId: state.currentThreadId || config.currentThreadId || null,
669
+ boundThreadId: config.currentThreadId || state.currentThreadId || null,
670
+ dispatchMode: config.dispatchMode,
671
+ idleCooldownMs: config.idleCooldownMs,
592
672
  queueDepth: queued.length,
673
+ ambientQueueDepth: ambient.length,
593
674
  submittedDepth: submitted.length,
594
675
  pendingReplyDepth: pendingReplies.length,
595
676
  lastInbound: state.lastInbound,
@@ -597,7 +678,7 @@ export function bridgeStatus() {
597
678
  lastPollAt: state.lastPollAt,
598
679
  lastInjectAt: state.lastInjectAt,
599
680
  stateDir: config.paths.root,
600
- note: "No autonomous shadow bot. Telegram is queued here and only injected into a real Codex thread on demand."
681
+ note: "Telegram first lands in queue. Automatic delivery waits for an idle session, skips ambient group noise, and still lets escalations through."
601
682
  };
602
683
  }
603
684
 
@@ -682,7 +763,7 @@ export async function pollOnce() {
682
763
  continue;
683
764
  }
684
765
  const inbound = normalizeInbound(update.message);
685
- if (config.allowedChatId && inbound.chatId !== config.allowedChatId) {
766
+ if (!isAllowedChat(config, inbound.chatId)) {
686
767
  ignored += 1;
687
768
  appendLog(config.paths.activityFile, `IGNORED chat=${inbound.chatId} message=${inbound.messageId}`);
688
769
  continue;
@@ -692,10 +773,11 @@ export async function pollOnce() {
692
773
  appendLog(config.paths.activityFile, `IGNORED_EMPTY chat=${inbound.chatId} message=${inbound.messageId}`);
693
774
  continue;
694
775
  }
776
+ inbound.relevance = classifyInboundRelevance(config, inbound);
695
777
  state.queue.push(inbound);
696
778
  state.lastInbound = inbound;
697
779
  appendJsonl(config.paths.inboxFile, inbound);
698
- appendLog(config.paths.activityFile, `IN chat=${inbound.chatId} message=${inbound.messageId} user=${inbound.user}: ${inbound.text.replace(/\s+/g, " ").slice(0, 180)}`);
780
+ appendLog(config.paths.activityFile, `IN chat=${inbound.chatId} message=${inbound.messageId} relevance=${inbound.relevance} user=${inbound.user}: ${inbound.text.replace(/\s+/g, " ").slice(0, 180)}`);
699
781
  captured += 1;
700
782
  }
701
783
 
@@ -717,9 +799,10 @@ export function listQueue(limit = 10) {
717
799
  return state.queue.slice(-Math.max(1, limit));
718
800
  }
719
801
 
720
- export async function injectNext(threadId) {
802
+ export async function injectNext(threadId, options = {}) {
721
803
  const config = loadConfig();
722
804
  const state = loadState(config);
805
+ const auto = Boolean(options.auto);
723
806
  const useAppServer = Boolean(config.appServerWsUrl);
724
807
  const preferredThreadId = (
725
808
  threadId
@@ -732,6 +815,53 @@ export async function injectNext(threadId) {
732
815
  throw new Error("No bound thread id. Use bridge_bind_current_thread first.");
733
816
  }
734
817
 
818
+ let next = null;
819
+ if (auto && String(config.dispatchMode || "deferred").toLowerCase() !== "legacy") {
820
+ next = state.queue.find((item) => {
821
+ if (item.status !== "queued") {
822
+ return false;
823
+ }
824
+ if (String(item.chatType || "") === "private") {
825
+ return true;
826
+ }
827
+ return ["direct", "lane", "escalation"].includes(String(item.relevance || ""));
828
+ });
829
+ } else {
830
+ next = state.queue.find((item) => item.status === "queued");
831
+ }
832
+
833
+ if (!next) {
834
+ return {
835
+ ok: true,
836
+ status: auto ? "deferred" : "empty",
837
+ reason: auto ? "no_eligible_message" : undefined
838
+ };
839
+ }
840
+
841
+ const bypassDeferredGate = auto && next.relevance === "escalation";
842
+ if (auto && !bypassDeferredGate && String(config.dispatchMode || "deferred").toLowerCase() !== "legacy") {
843
+ const openPendingReplies = countOpenPendingReplies(state);
844
+ if (openPendingReplies > 0) {
845
+ return {
846
+ ok: false,
847
+ status: "deferred",
848
+ reason: "pending_reply",
849
+ pendingReplies: openPendingReplies
850
+ };
851
+ }
852
+
853
+ const sessionActivity = await resolveSessionActivity(config, resolvedThreadId);
854
+ if (sessionActivity.active) {
855
+ return {
856
+ ok: false,
857
+ status: "deferred",
858
+ reason: "session_active",
859
+ quietMs: sessionActivity.quietMs,
860
+ readyInMs: Math.max(0, Number(config.idleCooldownMs || 0) - Number(sessionActivity.quietMs || 0))
861
+ };
862
+ }
863
+ }
864
+
735
865
  let promoted = 0;
736
866
  if (!useAppServer) {
737
867
  for (const entry of state.queue) {
@@ -744,14 +874,6 @@ export async function injectNext(threadId) {
744
874
  saveStateForConfig(config, state);
745
875
  }
746
876
 
747
- const next = state.queue.find((item) => item.status === "queued");
748
- if (!next) {
749
- return {
750
- ok: true,
751
- status: promoted > 0 ? "submitted" : "empty"
752
- };
753
- }
754
-
755
877
  next.attempts = Number(next.attempts || 0) + 1;
756
878
  next.lastAttemptAt = nowIso();
757
879
  let sessionPath = "";
@@ -803,6 +925,7 @@ export async function injectNext(threadId) {
803
925
  }
804
926
  }
805
927
  state.lastInjectAt = nowIso();
928
+ state.lastAutoDispatchAt = auto ? state.lastInjectAt : state.lastAutoDispatchAt;
806
929
  const latestState = loadState(config);
807
930
  saveStateForConfig(config, mergeStateSnapshots(latestState, state));
808
931
  appendLog(config.paths.activityFile, `INJECT_${result.ok ? "OK" : "ERROR"} thread=${resolvedThreadId} message=${next.messageId}`);
@@ -18,6 +18,13 @@ function readDotEnvFile(path) {
18
18
  return values;
19
19
  }
20
20
 
21
+ function parseAllowedChatIds(rawValue) {
22
+ return String(rawValue || "")
23
+ .split(",")
24
+ .map((value) => value.trim())
25
+ .filter(Boolean);
26
+ }
27
+
21
28
  export function loadConfig() {
22
29
  ensureStateLayout();
23
30
  const paths = getPaths();
@@ -27,17 +34,21 @@ export function loadConfig() {
27
34
  delete fallbackEnv.BLUN_TELEGRAM_AGENT_NAME;
28
35
  delete fallbackEnv.BLUN_TELEGRAM_STATE_DIR;
29
36
  delete fallbackEnv.BLUN_TELEGRAM_THREAD_ID;
30
- const env = { ...fallbackEnv, ...fileEnv, ...process.env };
37
+ const env = { ...fallbackEnv, ...process.env, ...fileEnv };
38
+ const allowedChatIds = parseAllowedChatIds(env.BLUN_TELEGRAM_ALLOWED_CHAT_ID || env.TELEGRAM_ALLOWED_CHAT_ID || "");
31
39
  return {
32
40
  paths,
33
- agentName: env.BLUN_TELEGRAM_AGENT_NAME?.trim() || "default",
41
+ agentName: env.BLUN_TELEGRAM_AGENT_NAME?.trim() || env.TELEGRAM_AGENT_NAME?.trim() || "default",
34
42
  lane: env.BLUN_CODEX_LANE?.trim() || "",
35
- botToken: env.BLUN_TELEGRAM_BOT_TOKEN?.trim() || "",
36
- allowedChatId: env.BLUN_TELEGRAM_ALLOWED_CHAT_ID?.trim() || "",
43
+ botToken: env.BLUN_TELEGRAM_BOT_TOKEN?.trim() || env.TELEGRAM_BOT_TOKEN?.trim() || "",
44
+ allowedChatId: allowedChatIds[0] || "",
45
+ allowedChatIds,
37
46
  codexBin: env.BLUN_TELEGRAM_CODEX_BIN?.trim() || "codex",
38
47
  appServerWsUrl: env.BLUN_TELEGRAM_APP_SERVER_WS_URL?.trim() || "",
39
48
  currentThreadId: env.BLUN_TELEGRAM_THREAD_ID?.trim() || process.env.CODEX_THREAD_ID?.trim() || "",
40
49
  resumeTimeoutMs: Number.parseInt(env.BLUN_TELEGRAM_RESUME_TIMEOUT_MS || "15000", 10) || 15000,
50
+ idleCooldownMs: Number.parseInt(env.BLUN_TELEGRAM_IDLE_COOLDOWN_MS || "15000", 10) || 15000,
51
+ dispatchMode: env.BLUN_TELEGRAM_DISPATCH_MODE?.trim() || "deferred",
41
52
  pluginMode: env.BLUN_TELEGRAM_PLUGIN_MODE?.trim() || "inherit",
42
53
  model: env.BLUN_CODEX_MODEL?.trim() || "",
43
54
  reasoningEffort: env.BLUN_CODEX_REASONING_EFFORT?.trim() || "",
@@ -59,10 +59,12 @@ function ensureSidecar(scriptName, pidFile, stdoutFile, stderrFile, config, opti
59
59
  BLUN_TELEGRAM_AGENT_NAME: config.agentName || "default",
60
60
  BLUN_TELEGRAM_STATE_DIR: config.paths.root,
61
61
  BLUN_TELEGRAM_BOT_TOKEN: config.botToken || "",
62
- BLUN_TELEGRAM_ALLOWED_CHAT_ID: config.allowedChatId || "",
62
+ BLUN_TELEGRAM_ALLOWED_CHAT_ID: Array.isArray(config.allowedChatIds) ? config.allowedChatIds.join(",") : (config.allowedChatId || ""),
63
63
  BLUN_TELEGRAM_APP_SERVER_WS_URL: config.appServerWsUrl || "",
64
64
  BLUN_TELEGRAM_CODEX_BIN: config.codexBin || "codex",
65
65
  BLUN_TELEGRAM_RESUME_TIMEOUT_MS: String(config.resumeTimeoutMs || 15000),
66
+ BLUN_TELEGRAM_IDLE_COOLDOWN_MS: String(config.idleCooldownMs || 15000),
67
+ BLUN_TELEGRAM_DISPATCH_MODE: config.dispatchMode || "deferred",
66
68
  BLUN_TELEGRAM_PLUGIN_MODE: config.pluginMode || "plugin",
67
69
  BLUN_CODEX_MODEL: config.model || "",
68
70
  BLUN_CODEX_REASONING_EFFORT: config.reasoningEffort || "",
@@ -1,49 +1,50 @@
1
- import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
2
-
3
- export function nowIso() {
4
- return new Date().toISOString();
5
- }
6
-
7
- export function loadJson(path, fallback) {
8
- try {
9
- const raw = readFileSync(path, "utf8").replace(/^\uFEFF/, "");
10
- return JSON.parse(raw);
11
- } catch {
12
- return fallback;
13
- }
14
- }
15
-
16
- export function saveJson(path, value) {
17
- writeFileSync(path, JSON.stringify(value, null, 2), "utf8");
18
- }
19
-
20
- export function appendJsonl(path, value) {
21
- appendFileSync(path, `${JSON.stringify(value)}\n`, "utf8");
22
- }
23
-
24
- export function appendLog(path, message) {
25
- appendFileSync(path, `${nowIso()} ${message}\n`, "utf8");
26
- }
27
-
28
- export function readTail(path, lines = 20) {
29
- if (!existsSync(path)) {
30
- return [];
31
- }
32
- const text = readFileSync(path, "utf8");
33
- return text.split(/\r?\n/).filter(Boolean).slice(-lines);
34
- }
35
-
36
- export function defaultState() {
37
- return {
38
- offset: 0,
39
- currentThreadId: "",
40
- queue: [],
41
- pendingReplies: [],
42
- replyOffsets: {},
43
- replyBuffers: {},
44
- lastInbound: null,
45
- lastOutbound: null,
46
- lastPollAt: null,
47
- lastInjectAt: null
48
- };
49
- }
1
+ import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
2
+
3
+ export function nowIso() {
4
+ return new Date().toISOString();
5
+ }
6
+
7
+ export function loadJson(path, fallback) {
8
+ try {
9
+ const raw = readFileSync(path, "utf8").replace(/^\uFEFF/, "");
10
+ return JSON.parse(raw);
11
+ } catch {
12
+ return fallback;
13
+ }
14
+ }
15
+
16
+ export function saveJson(path, value) {
17
+ writeFileSync(path, JSON.stringify(value, null, 2), "utf8");
18
+ }
19
+
20
+ export function appendJsonl(path, value) {
21
+ appendFileSync(path, `${JSON.stringify(value)}\n`, "utf8");
22
+ }
23
+
24
+ export function appendLog(path, message) {
25
+ appendFileSync(path, `${nowIso()} ${message}\n`, "utf8");
26
+ }
27
+
28
+ export function readTail(path, lines = 20) {
29
+ if (!existsSync(path)) {
30
+ return [];
31
+ }
32
+ const text = readFileSync(path, "utf8");
33
+ return text.split(/\r?\n/).filter(Boolean).slice(-lines);
34
+ }
35
+
36
+ export function defaultState() {
37
+ return {
38
+ offset: 0,
39
+ currentThreadId: "",
40
+ queue: [],
41
+ pendingReplies: [],
42
+ replyOffsets: {},
43
+ replyBuffers: {},
44
+ lastInbound: null,
45
+ lastOutbound: null,
46
+ lastPollAt: null,
47
+ lastInjectAt: null,
48
+ lastAutoDispatchAt: null
49
+ };
50
+ }
@@ -0,0 +1,245 @@
1
+ param(
2
+ [string]$Profile = "default",
3
+ [switch]$EnsureConfigured,
4
+ [switch]$Json
5
+ )
6
+
7
+ $ErrorActionPreference = "Stop"
8
+
9
+ function Try-ReadJson {
10
+ param([string]$Path)
11
+ if (-not (Test-Path $Path)) { return $null }
12
+ try { return Get-Content -Raw -Path $Path | ConvertFrom-Json } catch { return $null }
13
+ }
14
+
15
+ function Ensure-Dir {
16
+ param([string]$Path)
17
+ if (-not (Test-Path $Path)) {
18
+ New-Item -ItemType Directory -Path $Path -Force | Out-Null
19
+ }
20
+ }
21
+
22
+ function Resolve-ConfiguredPath {
23
+ param([string]$Value, [string]$RuntimeRoot)
24
+ if (-not $Value) { return "" }
25
+ $expanded = [Environment]::ExpandEnvironmentVariables($Value)
26
+ if ([System.IO.Path]::IsPathRooted($expanded)) { return $expanded }
27
+ return [System.IO.Path]::GetFullPath((Join-Path $RuntimeRoot $expanded))
28
+ }
29
+
30
+ function Read-DotEnvFile {
31
+ param([string]$Path)
32
+ $values = @{}
33
+ if (-not (Test-Path $Path)) { return $values }
34
+ foreach ($line in (Get-Content -Path $Path)) {
35
+ if (-not $line) { continue }
36
+ if ($line.Trim().StartsWith("#")) { continue }
37
+ $parts = $line -split "=", 2
38
+ if ($parts.Count -ne 2) { continue }
39
+ $values[$parts[0].Trim()] = $parts[1]
40
+ }
41
+ return $values
42
+ }
43
+
44
+ function Write-DotEnvFile {
45
+ param(
46
+ [string]$Path,
47
+ [hashtable]$Values
48
+ )
49
+
50
+ $lines = foreach ($key in ($Values.Keys | Sort-Object)) {
51
+ "$key=$($Values[$key])"
52
+ }
53
+ Set-Content -Path $Path -Value $lines -Encoding UTF8
54
+ }
55
+
56
+ function Test-TelegramTokenFormat {
57
+ param([string]$Value)
58
+ if (-not $Value) { return $false }
59
+ return $Value -match '^\d{6,}:[A-Za-z0-9_-]{20,}$'
60
+ }
61
+
62
+ function Test-AllowedChatIdsFormat {
63
+ param([string]$Value)
64
+ if (-not $Value) { return $false }
65
+ $parts = @($Value -split "," | ForEach-Object { $_.Trim() } | Where-Object { $_ })
66
+ if ($parts.Count -eq 0) { return $false }
67
+ foreach ($part in $parts) {
68
+ if ($part -notmatch '^-?\d+$') {
69
+ return $false
70
+ }
71
+ }
72
+ return $true
73
+ }
74
+
75
+ function Prompt-RequiredValue {
76
+ param(
77
+ [string]$Prompt,
78
+ [string]$CurrentValue,
79
+ [scriptblock]$Validator,
80
+ [string]$ErrorMessage
81
+ )
82
+
83
+ while ($true) {
84
+ $fullPrompt = if ($CurrentValue) { "$Prompt [$CurrentValue]" } else { $Prompt }
85
+ $inputValue = Read-Host -Prompt $fullPrompt
86
+ if (-not $inputValue -and $CurrentValue) {
87
+ $inputValue = $CurrentValue
88
+ }
89
+ $inputValue = [string]$inputValue
90
+ if (& $Validator $inputValue) {
91
+ return $inputValue
92
+ }
93
+ Write-Host $ErrorMessage -ForegroundColor Yellow
94
+ }
95
+ }
96
+
97
+ function Get-ProfilePath {
98
+ param(
99
+ [string]$RuntimeRoot,
100
+ [string]$ProfileName
101
+ )
102
+
103
+ $normalized = [string]$ProfileName
104
+ if (-not $normalized) { $normalized = "" }
105
+ $normalized = $normalized.ToLower()
106
+ $candidates = @()
107
+ if ($env:BLUN_CODEX_PROFILE_ROOT) {
108
+ $candidates += (Join-Path $env:BLUN_CODEX_PROFILE_ROOT ($normalized + ".json"))
109
+ }
110
+ $candidates += (Join-Path $env:USERPROFILE (".codex\\profiles\\codexlink\\" + $normalized + ".json"))
111
+ $candidates += (Join-Path $RuntimeRoot ("profiles\\" + $normalized + ".json"))
112
+
113
+ foreach ($candidate in $candidates) {
114
+ if ($candidate -and (Test-Path $candidate)) {
115
+ return $candidate
116
+ }
117
+ }
118
+
119
+ return $candidates[-1]
120
+ }
121
+
122
+ $runtimeRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
123
+ $profilePath = Get-ProfilePath -RuntimeRoot $runtimeRoot -ProfileName $Profile
124
+ $profileJson = Try-ReadJson -Path $profilePath
125
+
126
+ if (-not $profileJson) {
127
+ throw "Profile not found or invalid: $profilePath"
128
+ }
129
+
130
+ $profileAgent = if ($profileJson.agent_name) { [string]$profileJson.agent_name } else { $Profile.ToLower() }
131
+ $stateDir = if ($profileJson.telegram -and $profileJson.telegram.state_dir) {
132
+ Resolve-ConfiguredPath -Value ([string]$profileJson.telegram.state_dir) -RuntimeRoot $runtimeRoot
133
+ } else {
134
+ Join-Path $env:USERPROFILE (".codex\channels\telegram-" + $profileAgent)
135
+ }
136
+
137
+ Ensure-Dir -Path $stateDir
138
+ $envPath = Join-Path $stateDir ".env"
139
+ $envValues = Read-DotEnvFile -Path $envPath
140
+
141
+ $currentToken = [string]$envValues["BLUN_TELEGRAM_BOT_TOKEN"]
142
+ if (-not (Test-TelegramTokenFormat -Value $currentToken)) {
143
+ $currentToken = [string]$envValues["TELEGRAM_BOT_TOKEN"]
144
+ }
145
+
146
+ $currentAllowedChatIds = [string]$envValues["BLUN_TELEGRAM_ALLOWED_CHAT_ID"]
147
+ if (-not (Test-AllowedChatIdsFormat -Value $currentAllowedChatIds)) {
148
+ $currentAllowedChatIds = [string]$envValues["TELEGRAM_ALLOWED_CHAT_ID"]
149
+ }
150
+
151
+ $needsToken = -not (Test-TelegramTokenFormat -Value $currentToken)
152
+ $needsChatIds = -not (Test-AllowedChatIdsFormat -Value $currentAllowedChatIds)
153
+ $changed = $false
154
+
155
+ if ($EnsureConfigured -and -not $needsToken -and -not $needsChatIds) {
156
+ $result = [ordered]@{
157
+ ok = $true
158
+ changed = $false
159
+ profile = $profileAgent
160
+ state_dir = $stateDir
161
+ env_path = $envPath
162
+ missing = @()
163
+ }
164
+ if ($Json) {
165
+ $result | ConvertTo-Json -Depth 6
166
+ } else {
167
+ Write-Host "Telegram ist bereits eingerichtet fuer Profil '$profileAgent'." -ForegroundColor Green
168
+ Write-Host "State-Ordner: $stateDir"
169
+ }
170
+ exit 0
171
+ }
172
+
173
+ if ($EnsureConfigured -and $Json -and ($needsToken -or $needsChatIds)) {
174
+ $missing = @()
175
+ if ($needsToken) { $missing += "bot_token" }
176
+ if ($needsChatIds) { $missing += "allowed_chat_ids" }
177
+ [ordered]@{
178
+ ok = $false
179
+ changed = $false
180
+ profile = $profileAgent
181
+ state_dir = $stateDir
182
+ env_path = $envPath
183
+ missing = $missing
184
+ } | ConvertTo-Json -Depth 6
185
+ exit 2
186
+ }
187
+
188
+ if (-not $Json) {
189
+ Write-Host ""
190
+ Write-Host "CodexLink Telegram Setup" -ForegroundColor Cyan
191
+ Write-Host "Profil: $profileAgent"
192
+ Write-Host "Lokaler State-Ordner: $stateDir"
193
+ Write-Host ""
194
+ Write-Host "Ich speichere die Telegram-Werte automatisch an die richtige lokale Stelle." -ForegroundColor DarkGray
195
+ Write-Host "Du musst keine .env-Datei selbst suchen." -ForegroundColor DarkGray
196
+ Write-Host ""
197
+ }
198
+
199
+ if ($needsToken) {
200
+ $currentToken = Prompt-RequiredValue `
201
+ -Prompt "Telegram Bot Token" `
202
+ -CurrentValue $currentToken `
203
+ -Validator { param($v) Test-TelegramTokenFormat -Value $v } `
204
+ -ErrorMessage "Bitte einen gueltigen Telegram Bot Token eingeben. Beispiel: 123456789:ABC..."
205
+ $envValues["BLUN_TELEGRAM_BOT_TOKEN"] = $currentToken
206
+ $changed = $true
207
+ }
208
+
209
+ if ($needsChatIds) {
210
+ $currentAllowedChatIds = Prompt-RequiredValue `
211
+ -Prompt "Erlaubte Chat ID(s), komma-getrennt" `
212
+ -CurrentValue $currentAllowedChatIds `
213
+ -Validator { param($v) Test-AllowedChatIdsFormat -Value $v } `
214
+ -ErrorMessage "Bitte mindestens eine numerische Chat-ID eingeben. Mehrere IDs mit Komma trennen."
215
+ $envValues["BLUN_TELEGRAM_ALLOWED_CHAT_ID"] = (($currentAllowedChatIds -split "," | ForEach-Object { $_.Trim() } | Where-Object { $_ }) -join ",")
216
+ $changed = $true
217
+ }
218
+
219
+ $envValues["BLUN_TELEGRAM_AGENT_NAME"] = $profileAgent
220
+ $envValues["BLUN_TELEGRAM_STATE_DIR"] = $stateDir
221
+ Write-DotEnvFile -Path $envPath -Values $envValues
222
+
223
+ $result = [ordered]@{
224
+ ok = $true
225
+ changed = $changed
226
+ profile = $profileAgent
227
+ state_dir = $stateDir
228
+ env_path = $envPath
229
+ missing = @()
230
+ }
231
+
232
+ if ($Json) {
233
+ $result | ConvertTo-Json -Depth 6
234
+ exit 0
235
+ }
236
+
237
+ Write-Host ""
238
+ Write-Host "Telegram ist jetzt eingerichtet." -ForegroundColor Green
239
+ Write-Host "Gespeichert unter: $envPath"
240
+ Write-Host ""
241
+ Write-Host "Naechster Schritt:" -ForegroundColor Cyan
242
+ Write-Host " blun-codex --profile $profileAgent telegram-plugin"
243
+ Write-Host ""
244
+ Write-Host "Pruefen kannst du spaeter mit:" -ForegroundColor Cyan
245
+ Write-Host " blun-codex --profile $profileAgent telegram-doctor"