@rubytech/create-sitedesk-code 0.1.606 → 0.1.608

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.
Files changed (68) hide show
  1. package/dist/index.js +40 -1
  2. package/package.json +1 -1
  3. package/payload/platform/lib/admins-write/vitest.config.ts +1 -0
  4. package/payload/platform/lib/agent-dispatch-rule/vitest.config.ts +1 -0
  5. package/payload/platform/lib/brand-templating/vitest.config.ts +1 -0
  6. package/payload/platform/lib/canonical-instant/vitest.config.ts +1 -0
  7. package/payload/platform/lib/dispatch-read/vitest.config.ts +1 -0
  8. package/payload/platform/lib/graph-search/vitest.config.ts +1 -0
  9. package/payload/platform/lib/graph-style/vitest.config.ts +1 -0
  10. package/payload/platform/lib/mcp-lifeline/vitest.config.ts +1 -0
  11. package/payload/platform/lib/routine-templates/vitest.config.ts +1 -0
  12. package/payload/platform/lib/task-secrets/vitest.config.ts +1 -0
  13. package/payload/platform/lib/telegram-button-labels/vitest.config.ts +1 -0
  14. package/payload/platform/lib/telegram-managed-bot/vitest.config.ts +1 -0
  15. package/payload/platform/lib/telegram-media/vitest.config.ts +1 -0
  16. package/payload/platform/lib/telegram-reach/vitest.config.ts +1 -0
  17. package/payload/platform/lib/telegram-store-read/vitest.config.ts +1 -0
  18. package/payload/platform/plugins/admin/mcp/vitest.config.ts +1 -0
  19. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +39 -2
  20. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +10 -0
  21. package/payload/platform/plugins/aeo/mcp/vitest.config.ts +1 -0
  22. package/payload/platform/plugins/cloudflare/mcp/vitest.config.ts +1 -0
  23. package/payload/platform/plugins/connector/mcp/vitest.config.ts +1 -0
  24. package/payload/platform/plugins/contacts/mcp/vitest.config.ts +1 -0
  25. package/payload/platform/plugins/docs/references/telegram-guide.md +38 -1
  26. package/payload/platform/plugins/email/mcp/vitest.config.ts +1 -0
  27. package/payload/platform/plugins/filesystem/mcp/vitest.config.ts +1 -0
  28. package/payload/platform/plugins/graph-viewer/mcp/vitest.config.ts +1 -0
  29. package/payload/platform/plugins/memory/mcp/vitest.config.ts +1 -0
  30. package/payload/platform/plugins/scheduling/mcp/vitest.config.ts +1 -0
  31. package/payload/platform/plugins/storage-broker/mcp/vitest.config.ts +6 -1
  32. package/payload/platform/plugins/telegram/PLUGIN.md +6 -2
  33. package/payload/platform/plugins/telegram/mcp/dist/__tests__/card-tool.test.js +35 -3
  34. package/payload/platform/plugins/telegram/mcp/dist/__tests__/card-tool.test.js.map +1 -1
  35. package/payload/platform/plugins/telegram/mcp/dist/__tests__/instruct-tool.test.d.ts +2 -0
  36. package/payload/platform/plugins/telegram/mcp/dist/__tests__/instruct-tool.test.d.ts.map +1 -0
  37. package/payload/platform/plugins/telegram/mcp/dist/__tests__/instruct-tool.test.js +93 -0
  38. package/payload/platform/plugins/telegram/mcp/dist/__tests__/instruct-tool.test.js.map +1 -0
  39. package/payload/platform/plugins/telegram/mcp/dist/index.js +20 -0
  40. package/payload/platform/plugins/telegram/mcp/dist/index.js.map +1 -1
  41. package/payload/platform/plugins/telegram/mcp/dist/lib/public-pin.d.ts.map +1 -1
  42. package/payload/platform/plugins/telegram/mcp/dist/lib/public-pin.js +7 -0
  43. package/payload/platform/plugins/telegram/mcp/dist/lib/public-pin.js.map +1 -1
  44. package/payload/platform/plugins/telegram/mcp/dist/tools/card.d.ts.map +1 -1
  45. package/payload/platform/plugins/telegram/mcp/dist/tools/card.js +12 -5
  46. package/payload/platform/plugins/telegram/mcp/dist/tools/card.js.map +1 -1
  47. package/payload/platform/plugins/telegram/mcp/dist/tools/instruct.d.ts +56 -0
  48. package/payload/platform/plugins/telegram/mcp/dist/tools/instruct.d.ts.map +1 -0
  49. package/payload/platform/plugins/telegram/mcp/dist/tools/instruct.js +91 -0
  50. package/payload/platform/plugins/telegram/mcp/dist/tools/instruct.js.map +1 -0
  51. package/payload/platform/plugins/telegram/mcp/vitest.config.ts +1 -0
  52. package/payload/platform/plugins/url-get/mcp/vitest.config.ts +1 -0
  53. package/payload/platform/plugins/whatsapp/mcp/vitest.config.ts +1 -0
  54. package/payload/platform/scripts/repair-misfiled-card-rows.sh +276 -0
  55. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
  56. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +1 -0
  57. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
  58. package/payload/platform/services/claude-session-manager/vitest.config.ts +1 -1
  59. package/payload/platform/services/telegram-channel/dist/instructions.d.ts +1 -0
  60. package/payload/platform/services/telegram-channel/dist/instructions.d.ts.map +1 -1
  61. package/payload/platform/services/telegram-channel/dist/instructions.js +23 -0
  62. package/payload/platform/services/telegram-channel/dist/instructions.js.map +1 -1
  63. package/payload/platform/services/telegram-channel/dist/notification.d.ts +22 -7
  64. package/payload/platform/services/telegram-channel/dist/notification.d.ts.map +1 -1
  65. package/payload/platform/services/telegram-channel/dist/notification.js +17 -1
  66. package/payload/platform/services/telegram-channel/dist/notification.js.map +1 -1
  67. package/payload/platform/test/tmpdir-setup.ts +54 -0
  68. package/payload/server/server.js +1488 -1091
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import { execFileSync, spawn, spawnSync } from "node:child_process";
3
3
  import { existsSync, mkdirSync, writeFileSync, cpSync, readFileSync, rmSync, readdirSync, appendFileSync, openSync, closeSync, chmodSync, statSync, realpathSync, accessSync, constants as fsConstants } from "node:fs";
4
4
  import { clearBrandAgentRoot } from "./brand-agent-root.js";
5
5
  import { seedBypassPermissionsSettings, assertBypassPermissionsSeed } from "./permissions-seed.js";
6
- import { resolve, join, dirname } from "node:path";
6
+ import { resolve, join, dirname, basename } from "node:path";
7
7
  import { randomBytes } from "node:crypto";
8
8
  import { resolveInstallPortFromFs, buildMaxyUnitFile, buildClaudeSessionManagerUnitFile, buildEmailSamplerUnitFile, buildClaudePtysSliceUnitFile, buildCloudflaredSliceUnitFile, buildDarwinServiceEnvPath } from "./port-resolution.js";
9
9
  import { buildBrandCronBlock, mergeBrandCronBlock } from "./cron-registration.js";
@@ -570,6 +570,7 @@ function ensureNonSnapChromium() {
570
570
  }
571
571
  RESOLVED_CHROMIUM_BIN = decision.resolvedPath;
572
572
  }
573
+ RESOLVED_CHROMIUM_BIN = preferUnwrappedChromium(RESOLVED_CHROMIUM_BIN);
573
574
  // Defensive: never persist a snap-confined path. If realpath of the resolved
574
575
  // binary still lands under /snap/ (e.g. apt landed a snap package by mistake
575
576
  // on a misconfigured device), throw before writeChromiumBinaryPathFile sees
@@ -622,6 +623,44 @@ function runChromiumPostInstallGate(chromiumBin) {
622
623
  console.log(" Chromium post-install gate passed.");
623
624
  logFile(` [snap-chromium] post-install gate ok: ${chromiumBin} exit=0`);
624
625
  }
626
+ /**
627
+ * Prefer the real Chromium ELF binary over a distro shell wrapper.
628
+ *
629
+ * Debian and Raspberry Pi OS ship `/usr/bin/chromium` as a POSIX shell script
630
+ * that appends its own flags and then `exec $LIBDIR/$APPNAME "$@"`, where
631
+ * `LIBDIR=/usr/lib/$APPNAME` — so the real binary is derivable from the
632
+ * wrapper's own layout rather than guessed.
633
+ *
634
+ * On aarch64 with a page size above 4096 that wrapper adds
635
+ * `--js-flags=--no-decommit-pooled-pages` (its workaround for Debian #1089647).
636
+ * Current Chromium's V8 has removed that flag, so V8 exits "unrecognized flag",
637
+ * the GPU process dies with it, and the installer's CDP check then correctly
638
+ * finds nothing listening and aborts the upgrade. Measured on the beacons Pi
639
+ * 2026-08-14: Chromium 148.0.7778.167, `getconf PAGESIZE` 16384, and
640
+ * `chromium --js-flags=--no-decommit-pooled-pages` reproduces it directly.
641
+ *
642
+ * Execing the binary skips the wrapper and every flag it would inject. The
643
+ * wrapper is returned unchanged whenever the shape does not match — not a
644
+ * script, no binary at the derived path, or that path is not ELF — so a distro
645
+ * laying Chromium out differently is untouched.
646
+ */
647
+ function preferUnwrappedChromium(binPath) {
648
+ const fileType = (p) => {
649
+ const r = spawnSync("file", ["-b", p], { encoding: "utf-8", stdio: "pipe", timeout: 5_000 });
650
+ return r.status === 0 ? (r.stdout ?? "").trim() : "";
651
+ };
652
+ if (!/shell script/i.test(fileType(binPath)))
653
+ return binPath;
654
+ const appName = basename(binPath);
655
+ const real = `/usr/lib/${appName}/${appName}`;
656
+ if (!existsSync(real) || !/^ELF/.test(fileType(real))) {
657
+ logFile(` [chromium-wrapper] ${binPath} is a wrapper but ${real} is not a usable binary — keeping the wrapper`);
658
+ return binPath;
659
+ }
660
+ console.log(` Chromium wrapper bypassed: ${binPath} → ${real}`);
661
+ logFile(` [chromium-wrapper] bypassed wrapper=${binPath} binary=${real}`);
662
+ return real;
663
+ }
625
664
  /**
626
665
  * write the resolved Chromium absolute path to
627
666
  * `<INSTALL_DIR>/platform/config/chromium-binary.path` so vnc.sh and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-sitedesk-code",
3
- "version": "0.1.606",
3
+ "version": "0.1.608",
4
4
  "description": "Install SiteDesk — automated back office for independent building contractors",
5
5
  "bin": {
6
6
  "create-sitedesk-code": "./dist/index.js"
@@ -7,6 +7,7 @@ import { defineConfig } from "vitest/config";
7
7
  // is actually on disk.
8
8
  export default defineConfig({
9
9
  test: {
10
+ setupFiles: ["../../test/tmpdir-setup.ts"],
10
11
  environment: "node",
11
12
  globals: false,
12
13
  include: ["__tests__/**/*.test.ts"],
@@ -2,6 +2,7 @@ import { defineConfig } from "vitest/config";
2
2
 
3
3
  export default defineConfig({
4
4
  test: {
5
+ setupFiles: ["../../test/tmpdir-setup.ts"],
5
6
  environment: "node",
6
7
  globals: false,
7
8
  include: ["src/__tests__/**/*.test.ts"],
@@ -2,6 +2,7 @@ import { defineConfig } from "vitest/config";
2
2
 
3
3
  export default defineConfig({
4
4
  test: {
5
+ setupFiles: ["../../test/tmpdir-setup.ts"],
5
6
  environment: "node",
6
7
  globals: false,
7
8
  include: ["src/__tests__/**/*.test.ts"],
@@ -2,6 +2,7 @@ import { defineConfig } from "vitest/config";
2
2
 
3
3
  export default defineConfig({
4
4
  test: {
5
+ setupFiles: ["../../test/tmpdir-setup.ts"],
5
6
  environment: "node",
6
7
  globals: false,
7
8
  include: ["src/__tests__/**/*.test.ts"],
@@ -2,6 +2,7 @@ import { defineConfig } from "vitest/config";
2
2
 
3
3
  export default defineConfig({
4
4
  test: {
5
+ setupFiles: ["../../test/tmpdir-setup.ts"],
5
6
  environment: "node",
6
7
  globals: false,
7
8
  include: ["src/__tests__/**/*.test.ts"],
@@ -2,6 +2,7 @@ import { defineConfig } from 'vitest/config';
2
2
 
3
3
  export default defineConfig({
4
4
  test: {
5
+ setupFiles: ['../../test/tmpdir-setup.ts'],
5
6
  environment: 'node',
6
7
  globals: false,
7
8
  include: ['src/__tests__/**/*.test.ts'],
@@ -2,6 +2,7 @@ import { defineConfig } from "vitest/config";
2
2
 
3
3
  export default defineConfig({
4
4
  test: {
5
+ setupFiles: ["../../test/tmpdir-setup.ts"],
5
6
  environment: "node",
6
7
  globals: false,
7
8
  include: ["src/__tests__/**/*.test.ts"],
@@ -2,6 +2,7 @@ import { defineConfig } from "vitest/config";
2
2
 
3
3
  export default defineConfig({
4
4
  test: {
5
+ setupFiles: ["../../test/tmpdir-setup.ts"],
5
6
  environment: "node",
6
7
  globals: false,
7
8
  include: ["src/__tests__/**/*.test.ts"],
@@ -2,6 +2,7 @@ import { defineConfig } from "vitest/config";
2
2
 
3
3
  export default defineConfig({
4
4
  test: {
5
+ setupFiles: ["../../test/tmpdir-setup.ts"],
5
6
  environment: "node",
6
7
  globals: false,
7
8
  include: ["src/__tests__/**/*.test.ts"],
@@ -2,6 +2,7 @@ import { defineConfig } from 'vitest/config';
2
2
 
3
3
  export default defineConfig({
4
4
  test: {
5
+ setupFiles: ['../../test/tmpdir-setup.ts'],
5
6
  environment: 'node',
6
7
  globals: false,
7
8
  include: ['src/__tests__/**/*.test.ts'],
@@ -2,6 +2,7 @@ import { defineConfig } from "vitest/config";
2
2
 
3
3
  export default defineConfig({
4
4
  test: {
5
+ setupFiles: ["../../test/tmpdir-setup.ts"],
5
6
  environment: "node",
6
7
  globals: false,
7
8
  include: ["src/__tests__/**/*.test.ts"],
@@ -2,6 +2,7 @@ import { defineConfig } from "vitest/config";
2
2
 
3
3
  export default defineConfig({
4
4
  test: {
5
+ setupFiles: ["../../test/tmpdir-setup.ts"],
5
6
  environment: "node",
6
7
  globals: false,
7
8
  include: ["src/__tests__/**/*.test.ts"],
@@ -2,6 +2,7 @@ import { defineConfig } from "vitest/config";
2
2
 
3
3
  export default defineConfig({
4
4
  test: {
5
+ setupFiles: ["../../test/tmpdir-setup.ts"],
5
6
  environment: "node",
6
7
  globals: false,
7
8
  include: ["src/__tests__/**/*.test.ts"],
@@ -2,6 +2,7 @@ import { defineConfig } from "vitest/config";
2
2
 
3
3
  export default defineConfig({
4
4
  test: {
5
+ setupFiles: ["../../test/tmpdir-setup.ts"],
5
6
  environment: "node",
6
7
  globals: false,
7
8
  include: ["src/__tests__/**/*.test.ts"],
@@ -2,6 +2,7 @@ import { defineConfig } from "vitest/config";
2
2
 
3
3
  export default defineConfig({
4
4
  test: {
5
+ setupFiles: ["../../test/tmpdir-setup.ts"],
5
6
  environment: "node",
6
7
  globals: false,
7
8
  include: ["src/__tests__/**/*.test.ts"],
@@ -2,6 +2,7 @@ import { defineConfig } from "vitest/config";
2
2
 
3
3
  export default defineConfig({
4
4
  test: {
5
+ setupFiles: ["../../../test/tmpdir-setup.ts"],
5
6
  environment: "node",
6
7
  include: ["src/**/__tests__/**/*.test.ts"],
7
8
  testTimeout: 10_000,
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: platform-architecture
3
3
  description: Use when grounding any documented-surface claim about what SiteDesk ships — plugins, skills, specialists, install/deploy flows, internals. This is the install catalogue, not evidence of what is enabled on the current account. For install state on this account, call `capabilities-here`; for documented surface, cite the `Source:` URL inline.
4
- content-hash: sha256:be3aaf5fcd9b40508ba0a1d0e6b788422dc491432dec1eab64cbe8ce091139e6
4
+ content-hash: sha256:99c51bb52af33450b8add74edc7d5b0876a397e188837ad68dd847e9e1880308
5
5
  brand: sitedesk-code
6
6
  product-name: SiteDesk
7
7
  ---
@@ -2961,6 +2961,41 @@ Telegram profile name and will tell you plainly that it cannot put a name to the
2961
2961
  Ask SiteDesk to save the Telegram ID against the contact. It is one change to one contact, and
2962
2962
  it takes effect on the very next message.
2963
2963
 
2964
+ ## Correcting what a public agent told a visitor
2965
+
2966
+ When one of your public bots tells a visitor something wrong, the fix is to instruct the agent that
2967
+ is talking to them, not to message the visitor yourself.
2968
+
2969
+ The reason is worth understanding, because the obvious approach quietly fails. Every conversation is
2970
+ its own separate agent with its own memory. If you send the visitor a correction from your chat, they
2971
+ receive it, but the agent that has been talking to them never sees it: it is a different
2972
+ conversation, and nothing carries a message from yours into theirs. It carries on from what it
2973
+ believes, and repeats the mistake on the next question. That is exactly what happened on a live
2974
+ conversation where a corrected price was quoted wrong again ninety-seven seconds later.
2975
+
2976
+ So tell SiteDesk what the visitor should be told, and name the bot and the chat: "Tell the
2977
+ visitor on chat 1234567890 that the price is 75, not 65." The instruction goes into that visitor's
2978
+ own conversation. The agent writes the message itself, in the same voice it has been using, and its
2979
+ own memory now holds what it said, so it will not contradict it a moment later. The visitor sees a
2980
+ normal message from the assistant they have been talking to, with no sign that anybody stepped in.
2981
+
2982
+ Write the intent rather than the exact sentence. This is an instruction to the agent, not text
2983
+ delivered word for word, so "tell them we can do Thursday morning but not the afternoon" works better
2984
+ than a script.
2985
+
2986
+ Four situations are refused, and each one says why rather than failing quietly:
2987
+
2988
+ - The bot is not a public bot. Your own admin bot and any specialist bot are conversations with your
2989
+ own side, not with a visitor, so there is nothing to instruct.
2990
+ - The agent has been switched off. Nothing is resurrected behind your back; turn it back on, or
2991
+ message the person yourself.
2992
+ - The chat belongs to a connected business account. Those conversations are held separately and this
2993
+ route cannot reach them.
2994
+ - The chat is a broadcast channel the bot does not allow. A channel post reaches every subscriber and
2995
+ cannot be unsent.
2996
+
2997
+ If you need the chat ID, ask "who can you reach on Telegram?" first.
2998
+
2964
2999
  ## Getting a Chat ID
2965
3000
 
2966
3001
  To message a specific person or group, SiteDesk needs their chat ID. The easiest way:
@@ -3047,7 +3082,9 @@ it puts the answer in the card itself and does not add a sentence beside it. On
3047
3082
  public bot, that following sentence is held back on purpose, so the visitor sees
3048
3083
  the card once and not the same thing twice. And when someone presses a button,
3049
3084
  your conversation view shows the button's own wording — "1 passenger", "One way"
3050
- — rather than the code behind it.
3085
+ — rather than the code behind it. A card SiteDesk sends into a different
3086
+ chat appears in that chat's conversation, where the person who received it is,
3087
+ and not in the conversation you asked for it from.
3051
3088
 
3052
3089
  The same is now true of most of what Telegram sends. Reply to one of
3053
3090
  SiteDesk's messages and it sees the message you replied to, so "yes, do
@@ -9,6 +9,16 @@ Invoked by the admin agent directly.
9
9
 
10
10
  This is the platform's release timeline, newest first. Each entry shows the date it shipped and the version it shipped in, so you can tell the operator how current their install is. To compare, read the installed version from `capabilities-here` and match it against the versions below. Keep answers high level and in plain English; this is a summary, not a full commit log.
11
11
 
12
+ ## 2026-08-15 (0.1.608)
13
+
14
+ - You can now steer a public agent while it is mid-conversation with someone, rather than waiting for the exchange to end.
15
+ - A card sent into a different chat is filed in that chat, not in the conversation of whoever sent it.
16
+ - Routines already running on your installs now use the work-check gates added over the past week. They kept their old always-run behaviour until this backfill, and routines left behind with nothing to run are removed.
17
+
18
+ ## 2026-08-14 (0.1.607)
19
+
20
+ - Upgrades no longer fail on a Raspberry Pi with a 16K page size. The system's own Chromium launcher adds a setting that current Chromium has removed, so the browser exited on startup and the install stopped when it could not reach it. The installer now runs Chromium directly, skipping that launcher.
21
+
12
22
  ## 2026-08-14 (0.1.606)
13
23
 
14
24
  - Conversations start after an install again. The installer marked only your home folder as trusted, and the assistant now asks about the folder each account actually runs from, so a session would stop at a trust prompt nobody could answer.
@@ -2,6 +2,7 @@ import { defineConfig } from "vitest/config";
2
2
 
3
3
  export default defineConfig({
4
4
  test: {
5
+ setupFiles: ["../../../test/tmpdir-setup.ts"],
5
6
  environment: "node",
6
7
  include: ["src/**/__tests__/**/*.test.ts"],
7
8
  testTimeout: 10_000,
@@ -2,6 +2,7 @@ import { defineConfig } from "vitest/config";
2
2
 
3
3
  export default defineConfig({
4
4
  test: {
5
+ setupFiles: ["../../../test/tmpdir-setup.ts"],
5
6
  environment: "node",
6
7
  include: ["__tests__/**/*.test.ts"],
7
8
  exclude: process.env.RUN_LIVE === "1" ? [] : ["__tests__/live/**"],
@@ -2,6 +2,7 @@ import { defineConfig } from "vitest/config";
2
2
 
3
3
  export default defineConfig({
4
4
  test: {
5
+ setupFiles: ["../../../test/tmpdir-setup.ts"],
5
6
  environment: "node",
6
7
  include: ["src/**/__tests__/**/*.test.ts"],
7
8
  },
@@ -2,6 +2,7 @@ import { defineConfig } from "vitest/config";
2
2
 
3
3
  export default defineConfig({
4
4
  test: {
5
+ setupFiles: ["../../../test/tmpdir-setup.ts"],
5
6
  environment: "node",
6
7
  // Scoped to src, matching plugins/telegram/mcp: without it vitest also
7
8
  // collects dist/__tests__/*.test.js, which `tsc -p` emits because this
@@ -138,6 +138,41 @@ Telegram profile name and will tell you plainly that it cannot put a name to the
138
138
  Ask {{productName}} to save the Telegram ID against the contact. It is one change to one contact, and
139
139
  it takes effect on the very next message.
140
140
 
141
+ ## Correcting what a public agent told a visitor
142
+
143
+ When one of your public bots tells a visitor something wrong, the fix is to instruct the agent that
144
+ is talking to them, not to message the visitor yourself.
145
+
146
+ The reason is worth understanding, because the obvious approach quietly fails. Every conversation is
147
+ its own separate agent with its own memory. If you send the visitor a correction from your chat, they
148
+ receive it, but the agent that has been talking to them never sees it: it is a different
149
+ conversation, and nothing carries a message from yours into theirs. It carries on from what it
150
+ believes, and repeats the mistake on the next question. That is exactly what happened on a live
151
+ conversation where a corrected price was quoted wrong again ninety-seven seconds later.
152
+
153
+ So tell {{productName}} what the visitor should be told, and name the bot and the chat: "Tell the
154
+ visitor on chat 1234567890 that the price is 75, not 65." The instruction goes into that visitor's
155
+ own conversation. The agent writes the message itself, in the same voice it has been using, and its
156
+ own memory now holds what it said, so it will not contradict it a moment later. The visitor sees a
157
+ normal message from the assistant they have been talking to, with no sign that anybody stepped in.
158
+
159
+ Write the intent rather than the exact sentence. This is an instruction to the agent, not text
160
+ delivered word for word, so "tell them we can do Thursday morning but not the afternoon" works better
161
+ than a script.
162
+
163
+ Four situations are refused, and each one says why rather than failing quietly:
164
+
165
+ - The bot is not a public bot. Your own admin bot and any specialist bot are conversations with your
166
+ own side, not with a visitor, so there is nothing to instruct.
167
+ - The agent has been switched off. Nothing is resurrected behind your back; turn it back on, or
168
+ message the person yourself.
169
+ - The chat belongs to a connected business account. Those conversations are held separately and this
170
+ route cannot reach them.
171
+ - The chat is a broadcast channel the bot does not allow. A channel post reaches every subscriber and
172
+ cannot be unsent.
173
+
174
+ If you need the chat ID, ask "who can you reach on Telegram?" first.
175
+
141
176
  ## Getting a Chat ID
142
177
 
143
178
  To message a specific person or group, {{productName}} needs their chat ID. The easiest way:
@@ -224,7 +259,9 @@ it puts the answer in the card itself and does not add a sentence beside it. On
224
259
  public bot, that following sentence is held back on purpose, so the visitor sees
225
260
  the card once and not the same thing twice. And when someone presses a button,
226
261
  your conversation view shows the button's own wording — "1 passenger", "One way"
227
- — rather than the code behind it.
262
+ — rather than the code behind it. A card {{productName}} sends into a different
263
+ chat appears in that chat's conversation, where the person who received it is,
264
+ and not in the conversation you asked for it from.
228
265
 
229
266
  The same is now true of most of what Telegram sends. Reply to one of
230
267
  {{productName}}'s messages and it sees the message you replied to, so "yes, do
@@ -2,6 +2,7 @@ import { defineConfig } from "vitest/config";
2
2
 
3
3
  export default defineConfig({
4
4
  test: {
5
+ setupFiles: ["../../../test/tmpdir-setup.ts"],
5
6
  environment: "node",
6
7
  include: ["src/**/__tests__/**/*.test.ts"],
7
8
  testTimeout: 10_000,
@@ -2,6 +2,7 @@ import { defineConfig } from "vitest/config";
2
2
 
3
3
  export default defineConfig({
4
4
  test: {
5
+ setupFiles: ["../../../test/tmpdir-setup.ts"],
5
6
  include: ["src/**/__tests__/**/*.test.ts"],
6
7
  environment: "node",
7
8
  },
@@ -2,6 +2,7 @@ import { defineConfig } from "vitest/config";
2
2
 
3
3
  export default defineConfig({
4
4
  test: {
5
+ setupFiles: ["../../../test/tmpdir-setup.ts"],
5
6
  include: ["src/**/*.test.ts"],
6
7
  environment: "node",
7
8
  },
@@ -2,6 +2,7 @@ import { defineConfig } from "vitest/config";
2
2
 
3
3
  export default defineConfig({
4
4
  test: {
5
+ setupFiles: ["../../../test/tmpdir-setup.ts"],
5
6
  environment: "node",
6
7
  // narrow to `src/tools/__tests__/` so vitest does not pick up
7
8
  // pre-existing `src/lib/__tests__/` files that use Node's built-in
@@ -2,6 +2,7 @@ import { defineConfig } from "vitest/config";
2
2
 
3
3
  export default defineConfig({
4
4
  test: {
5
+ setupFiles: ["../../../test/tmpdir-setup.ts"],
5
6
  environment: "node",
6
7
  include: ["src/**/__tests__/**/*.test.ts"],
7
8
  testTimeout: 10_000,
@@ -1,4 +1,9 @@
1
1
  import { defineConfig } from "vitest/config";
2
2
  export default defineConfig({
3
- test: { environment: "node", include: ["src/**/__tests__/**/*.test.ts"], testTimeout: 10_000 },
3
+ test: {
4
+ setupFiles: ["../../../test/tmpdir-setup.ts"],
5
+ environment: "node",
6
+ include: ["src/**/__tests__/**/*.test.ts"],
7
+ testTimeout: 10_000,
8
+ },
4
9
  });
@@ -56,6 +56,10 @@ tools:
56
56
  publicAllowlist: false
57
57
  adminAllowlist: true
58
58
  riskClass: read
59
+ - name: telegram-instruct
60
+ publicAllowlist: false
61
+ adminAllowlist: true
62
+ riskClass: external
59
63
  mcp:
60
64
  command: node
61
65
  args:
@@ -136,9 +140,9 @@ Two group failures emit nothing on their own and neither reproduces on demand: a
136
140
 
137
141
  The channel no-event failure is the bot losing its post rights or being removed as an administrator: every later send fails one at a time and nothing announces it. The hourly `[telegram-audit] op=channel-census bots=… channels=… admin=… notAdmin=… noPostRight=…` reconciles it. `noPostRight>0` is a bot that is still an administrator but cannot post, which from the agent's side is indistinguishable from a channel nobody reads.
138
142
 
139
- **What a public bot can send.** A visitor's bot holds four tools: `telegram-card` (text with tappable buttons, a button that opens a page inside Telegram as a Mini App, and edit-in-place by key), `telegram-ask` (polls, quizzes, business checklists, map pins, venues, contact cards, dice), `telegram-media-send` (photo, document, video, audio, voice, animation, video note, 2-10 albums) and `telegram-media-fetch` (opening a file the visitor sent). The other nine stay admin-only: the untyped API passthrough, anything that mutates the bot registry, the bot-wide Mini App menu button, and `telegram-reach`, which lists every chat that has messaged a bot and so would be a cross-visitor read.
143
+ **What a public bot can send.** A visitor's bot holds four tools: `telegram-card` (text with tappable buttons, a button that opens a page inside Telegram as a Mini App, and edit-in-place by key), `telegram-ask` (polls, quizzes, business checklists, map pins, venues, contact cards, dice), `telegram-media-send` (photo, document, video, audio, voice, animation, video note, 2-10 albums) and `telegram-media-fetch` (opening a file the visitor sent). The other ten stay admin-only: the untyped API passthrough, anything that mutates the bot registry, the bot-wide Mini App menu button, `telegram-reach`, which lists every chat that has messaged a bot and so would be a cross-visitor read, and `telegram-instruct`, which steers what an agent says inside another chat and in a visitor's hands would let one visitor put words in an agent's mouth to another.
140
144
 
141
- The other nine are not merely denied on a public spawn — they are never registered, so they are absent from the model's tool list and it cannot spend a visitor's turn calling one. Advertisement and permission are separate gates and this is the first: `PUBLIC_VISIBLE_TOOLS` in `mcp/src/lib/public-pin.ts` is the set, reconciled in both directions against the `publicAllowlist: true` quads above so the two cannot drift. A pinned spawn says what it registered on boot: `[telegram-tools] op=public-surface registered=4 tools=telegram-ask,telegram-card,telegram-media-fetch,telegram-media-send`. Any other name on that line, or a count above four, is the breach — greppable at spawn, with no visitor turn needed to expose it.
145
+ The other ten are not merely denied on a public spawn — they are never registered, so they are absent from the model's tool list and it cannot spend a visitor's turn calling one. Advertisement and permission are separate gates and this is the first: `PUBLIC_VISIBLE_TOOLS` in `mcp/src/lib/public-pin.ts` is the set, reconciled in both directions against the `publicAllowlist: true` quads above so the two cannot drift. A pinned spawn says what it registered on boot: `[telegram-tools] op=public-surface registered=4 tools=telegram-ask,telegram-card,telegram-media-fetch,telegram-media-send`. Any other name on that line, or a count above four, is the breach — greppable at spawn, with no visitor turn needed to expose it.
142
146
 
143
147
  That line reaches `server.log` through the loopback log-ingest route, so it sits beside the `op=mcp-registered` line for the same spawn. The plugin's stderr does not: it lands in `~/.<brand>/logs/mcp-telegram-<sessionId>.log`, and the spawn-tee mirrors only its own `[mcp-helper]` lifecycle lines onward. When the route cannot take it the plugin says so rather than going quiet — `[telegram-tools] op=public-surface-unposted reason=<no-platform-port|unreachable|status-NNN>` in the per-session file — so an absent census in `server.log` with no `op=public-surface-unposted` anywhere is a spawn that never registered, which is a different fault from a mirror that failed.
144
148
 
@@ -380,7 +380,7 @@ describe("telegramCard — result message id and card-result token (Task 2677)",
380
380
  const out = await telegramCard({ botId: BOT, chatId: 222, text: "hi" }, deps(fetchImpl));
381
381
  expect(out.outcome).toBe("sent");
382
382
  expect(out.messageId).toBe(4242);
383
- expect(out.text).toMatch(/card-result: outcome=sent messageId=4242$/);
383
+ expect(out.text).toMatch(/card-result: outcome=sent messageId=4242 /);
384
384
  });
385
385
  it("carries the reused id on an edit", async () => {
386
386
  const first = recorder([sent(500)]);
@@ -389,7 +389,7 @@ describe("telegramCard — result message id and card-result token (Task 2677)",
389
389
  const out = await telegramCard({ botId: BOT, chatId: 222, key: "j", text: "two" }, deps(second.fetchImpl));
390
390
  expect(out.outcome).toBe("edited");
391
391
  expect(out.messageId).toBe(500);
392
- expect(out.text).toMatch(/card-result: outcome=edited messageId=500$/);
392
+ expect(out.text).toMatch(/card-result: outcome=edited messageId=500 /);
393
393
  });
394
394
  it("emits messageId=none and no id on a refused send", async () => {
395
395
  const { fetchImpl } = recorder([
@@ -398,7 +398,7 @@ describe("telegramCard — result message id and card-result token (Task 2677)",
398
398
  const out = await telegramCard({ botId: BOT, chatId: 222, text: "hi" }, deps(fetchImpl));
399
399
  expect(out.outcome).toBe("none");
400
400
  expect(out.messageId).toBeUndefined();
401
- expect(out.text).toMatch(/card-result: outcome=none messageId=none$/);
401
+ expect(out.text).toMatch(/card-result: outcome=none messageId=none /);
402
402
  });
403
403
  it("does not append a token when the card never reached Telegram (unknown bot)", async () => {
404
404
  const { fetchImpl } = recorder([]);
@@ -406,6 +406,38 @@ describe("telegramCard — result message id and card-result token (Task 2677)",
406
406
  expect(out.text).not.toContain("card-result:");
407
407
  });
408
408
  });
409
+ // Task 2723 — the bot and chat the card was ACTUALLY sent to. The follower keys
410
+ // the operator-surface store row on these, so a card the admin door sends into
411
+ // another chat is filed in that chat's conversation rather than the sender's.
412
+ describe("telegramCard — the trailer names the destination (Task 2723)", () => {
413
+ it("names the bot and chat the card was sent to", async () => {
414
+ const { fetchImpl } = recorder([sent(4242)]);
415
+ const out = await telegramCard({ botId: BOT, chatId: 222, text: "hi" }, deps(fetchImpl));
416
+ expect(out.text).toMatch(/card-result: outcome=sent messageId=4242 botId=111 chatId=222$/);
417
+ });
418
+ it("names them on an edit too, so the patch reaches the same conversation", async () => {
419
+ const first = recorder([sent(500)]);
420
+ await telegramCard({ botId: BOT, chatId: 222, key: "j", text: "one" }, deps(first.fetchImpl));
421
+ const second = recorder([sent(500)]);
422
+ const out = await telegramCard({ botId: BOT, chatId: 222, key: "j", text: "two" }, deps(second.fetchImpl));
423
+ expect(out.text).toMatch(/card-result: outcome=edited messageId=500 botId=111 chatId=222$/);
424
+ });
425
+ it("carries the PINNED pair, not the one the model supplied", async () => {
426
+ // Through runPinned, which is the only path a public spawn's card takes:
427
+ // applyPublicPin overwrites both fields before telegramCard is entered, so
428
+ // reading the supplied input here would record the model's guess.
429
+ const { fetchImpl } = recorder([sent(9)]);
430
+ const out = await runPinned("telegram-card", { botId: "999", chatId: 777, text: "hi" }, telegramCard, () => ({ ...deps(fetchImpl), publicPin: { botId: BOT, chatId: "222" } }));
431
+ expect(out.content[0].text).toMatch(/card-result: outcome=sent messageId=9 botId=111 chatId=222$/);
432
+ });
433
+ it("names them on a refused send as well, alongside messageId=none", async () => {
434
+ const { fetchImpl } = recorder([
435
+ { ok: false, error_code: 403, description: "Forbidden: bot was blocked by the user" },
436
+ ]);
437
+ const out = await telegramCard({ botId: BOT, chatId: 222, text: "hi" }, deps(fetchImpl));
438
+ expect(out.text).toMatch(/card-result: outcome=none messageId=none botId=111 chatId=222$/);
439
+ });
440
+ });
409
441
  // Task 2677 — a keyed card whose stored id sits below the chat's newest inbound id
410
442
  // is buried above the customer's later messages, so it is deleted and reposted at
411
443
  // the bottom. A refused delete falls back to edit-in-place.