talon-agent 4.3.2 → 4.3.3

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 (61) hide show
  1. package/package.json +1 -1
  2. package/src/backend/claude-sdk/doctor.ts +1 -1
  3. package/src/backend/codex/doctor.ts +1 -1
  4. package/src/backend/openai-agents/doctor.ts +1 -1
  5. package/src/bootstrap.ts +1 -1
  6. package/src/cli/doctor.ts +1 -1
  7. package/src/core/agent-runtime/backend-registry.ts +1 -1
  8. package/src/core/auth/expiry-monitor.ts +1 -1
  9. package/src/core/{doctor.ts → doctor/index.ts} +11 -11
  10. package/src/core/{notify.ts → frontend-runtime/admin-notify.ts} +1 -1
  11. package/src/core/plugin/native-runtimes.ts +1 -1
  12. package/src/frontend/discord/commands/admin.ts +1 -1
  13. package/src/frontend/discord/render.ts +1 -1
  14. package/src/frontend/telegram/actions/index.ts +1 -1
  15. package/src/frontend/telegram/actions/moderation/chat.ts +63 -0
  16. package/src/frontend/telegram/actions/moderation/index.ts +74 -0
  17. package/src/frontend/telegram/actions/moderation/members.ts +77 -0
  18. package/src/frontend/telegram/actions/moderation/permissions.ts +69 -0
  19. package/src/frontend/telegram/actions/moderation/topics.ts +48 -0
  20. package/src/frontend/telegram/actions/moderation/types.ts +16 -0
  21. package/src/frontend/telegram/admin/background.ts +66 -0
  22. package/src/frontend/telegram/admin/chunked-reply.ts +20 -0
  23. package/src/frontend/telegram/admin/health.ts +81 -0
  24. package/src/frontend/telegram/admin/sessions.ts +105 -0
  25. package/src/frontend/telegram/admin.ts +56 -261
  26. package/src/frontend/telegram/callbacks/model/backend.ts +156 -0
  27. package/src/frontend/telegram/callbacks/model/control.ts +29 -0
  28. package/src/frontend/telegram/callbacks/model/nav.ts +54 -0
  29. package/src/frontend/telegram/callbacks/model/select.ts +100 -0
  30. package/src/frontend/telegram/callbacks/model/types.ts +39 -0
  31. package/src/frontend/telegram/callbacks/model/views.ts +116 -0
  32. package/src/frontend/telegram/callbacks/model.ts +61 -331
  33. package/src/frontend/telegram/commands/admin.ts +108 -77
  34. package/src/frontend/telegram/commands/settings.ts +65 -43
  35. package/src/frontend/telegram/commands/whatsapp-pairing.ts +1 -1
  36. package/src/frontend/telegram/helpers/diagnostics.ts +1 -1
  37. package/src/frontend/telegram/middleware.ts +166 -201
  38. package/src/frontend/terminal/builtins/context.ts +184 -0
  39. package/src/frontend/terminal/builtins/help.ts +35 -0
  40. package/src/frontend/terminal/builtins/index.ts +23 -0
  41. package/src/frontend/terminal/builtins/model.ts +198 -0
  42. package/src/frontend/terminal/builtins/session.ts +101 -0
  43. package/src/frontend/terminal/builtins/status.ts +202 -0
  44. package/src/frontend/terminal/command-registry.ts +80 -0
  45. package/src/frontend/terminal/commands.ts +11 -674
  46. package/src/frontend/terminal/input/history.ts +64 -0
  47. package/src/frontend/terminal/input/keys.ts +143 -0
  48. package/src/frontend/terminal/input/parts.ts +33 -0
  49. package/src/frontend/terminal/input/paste.ts +53 -0
  50. package/src/frontend/terminal/input/render.ts +42 -0
  51. package/src/frontend/terminal/input/state.ts +60 -0
  52. package/src/frontend/terminal/input.ts +27 -342
  53. package/src/frontend/whatsapp/connection.ts +1 -1
  54. package/src/frontend/whatsapp/index.ts +1 -1
  55. package/src/frontend/whatsapp/pairing-service.ts +1 -1
  56. package/src/plugins/github/provision.ts +1 -1
  57. package/src/plugins/mempalace/provision.ts +1 -1
  58. package/src/plugins/playwright/provision.ts +1 -1
  59. package/src/frontend/telegram/actions/moderation.ts +0 -255
  60. /package/src/core/{doctor-types.ts → doctor/types.ts} +0 -0
  61. /package/src/core/{pairing-broker.ts → frontend-runtime/pairing-broker.ts} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "4.3.2",
3
+ "version": "4.3.3",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
@@ -7,7 +7,7 @@
7
7
  import type {
8
8
  DoctorCheck,
9
9
  DoctorConfigSlice,
10
- } from "../../core/doctor-types.js";
10
+ } from "../../core/doctor/types.js";
11
11
  import { getModels } from "../../core/models/catalog.js";
12
12
  import { binaryOnPath } from "../../util/binary-on-path.js";
13
13
  import { resolveModel } from "./model-provider.js";
@@ -6,7 +6,7 @@
6
6
  import type {
7
7
  DoctorCheck,
8
8
  DoctorConfigSlice,
9
- } from "../../core/doctor-types.js";
9
+ } from "../../core/doctor/types.js";
10
10
  import { binaryOnPath } from "../../util/binary-on-path.js";
11
11
  import { detectCodexAuth } from "./auth.js";
12
12
 
@@ -6,7 +6,7 @@
6
6
  import type {
7
7
  DoctorCheck,
8
8
  DoctorConfigSlice,
9
- } from "../../core/doctor-types.js";
9
+ } from "../../core/doctor/types.js";
10
10
 
11
11
  export async function openAIAgentsDoctorChecks(
12
12
  config: DoctorConfigSlice | undefined,
package/src/bootstrap.ts CHANGED
@@ -27,7 +27,7 @@ import { appendToJournal } from "./storage/journal.js";
27
27
  import { initPulse, resetPulseTimer } from "./core/background/pulse.js";
28
28
  import { initCron } from "./core/background/cron.js";
29
29
  import { initPlanAlerts } from "./core/background/plan-alerts.js";
30
- import { setAdminNotifier } from "./core/notify.js";
30
+ import { setAdminNotifier } from "./core/frontend-runtime/admin-notify.js";
31
31
  import { startAuthExpiryMonitor } from "./core/auth/expiry-monitor.js";
32
32
  import {
33
33
  initTriggers,
package/src/cli/doctor.ts CHANGED
@@ -19,7 +19,7 @@ const DOCTOR_ICONS: Record<string, string> = {
19
19
  export async function runDoctor(): Promise<void> {
20
20
  printBanner();
21
21
  console.log(` ${pc.bold("Environment check")}\n`);
22
- const { collectDoctorReport } = await import("../core/doctor.js");
22
+ const { collectDoctorReport } = await import("../core/doctor/index.js");
23
23
  // Doctor composes each backend's own checks off the registry, and the
24
24
  // CLI runs standalone — nothing else has registered them yet.
25
25
  const { loadBuiltinBackends } = await import("../backend/builtins.js");
@@ -30,7 +30,7 @@
30
30
  */
31
31
 
32
32
  import type { Backend } from "./capabilities.js";
33
- import type { DoctorCheck, DoctorConfigSlice } from "../doctor-types.js";
33
+ import type { DoctorCheck, DoctorConfigSlice } from "../doctor/types.js";
34
34
  import type { TalonConfig } from "../../util/config.js";
35
35
 
36
36
  // ── Types ───────────────────────────────────────────────────────────────────
@@ -9,7 +9,7 @@
9
9
  * fresh login clears it, so the next expiry warns again).
10
10
  */
11
11
 
12
- import { notifyAdmin } from "../notify.js";
12
+ import { notifyAdmin } from "../frontend-runtime/admin-notify.js";
13
13
  import {
14
14
  daysUntil,
15
15
  PROVIDER_LABELS,
@@ -13,22 +13,22 @@
13
13
 
14
14
  import { existsSync } from "node:fs";
15
15
  import { stat } from "node:fs/promises";
16
- import { NATIVE_MODULES } from "../native/registry.js";
17
- import { dirs } from "../util/paths.js";
18
- import { getBackend, listBackends } from "./agent-runtime/backend-registry.js";
16
+ import { NATIVE_MODULES } from "../../native/registry.js";
17
+ import { dirs } from "../../util/paths.js";
18
+ import { getBackend, listBackends } from "../agent-runtime/backend-registry.js";
19
19
  import type {
20
20
  DoctorCheck,
21
21
  DoctorConfigSlice,
22
22
  DoctorReport,
23
23
  NativeModuleCheck,
24
- } from "./doctor-types.js";
24
+ } from "./types.js";
25
25
 
26
26
  export type {
27
27
  DoctorCheck,
28
28
  DoctorConfigSlice,
29
29
  DoctorReport,
30
30
  NativeModuleCheck,
31
- } from "./doctor-types.js";
31
+ } from "./types.js";
32
32
 
33
33
  function errorNote(err: unknown): string {
34
34
  return err instanceof Error ? err.message : String(err);
@@ -180,7 +180,7 @@ async function checkPluginRuntimes(
180
180
  config: DoctorConfigSlice | undefined,
181
181
  ): Promise<DoctorCheck[]> {
182
182
  const checks: DoctorCheck[] = [];
183
- const { NATIVE_RUNTIMES } = await import("./plugin/native-runtimes.js");
183
+ const { NATIVE_RUNTIMES } = await import("../plugin/native-runtimes.js");
184
184
  for (const runtime of NATIVE_RUNTIMES) {
185
185
  if (!runtime.enabled(config)) continue;
186
186
  try {
@@ -322,7 +322,7 @@ export async function collectDoctorReport(opts: {
322
322
  // on the in-process TS path. When present, report the live version.
323
323
  {
324
324
  const { wardenBinaryPath, wardenVersion } =
325
- await import("../native/warden.js");
325
+ await import("../../native/warden.js");
326
326
  const wardenBin = wardenBinaryPath();
327
327
  if (wardenBin) {
328
328
  const version = wardenVersion();
@@ -350,7 +350,7 @@ export async function collectDoctorReport(opts: {
350
350
  // and on the event loop), so it's informational, not an issue. The
351
351
  // addon was digest-verified at load time by nativeBlake3().
352
352
  {
353
- const { nativeBlake3 } = await import("../native/blake3.js");
353
+ const { nativeBlake3 } = await import("../../native/blake3.js");
354
354
  const addon = nativeBlake3();
355
355
  checks.push(
356
356
  addon
@@ -370,7 +370,7 @@ export async function collectDoctorReport(opts: {
370
370
  // ~/.talon/ns is the symlink farm only (file mounts fine, no live
371
371
  // proc//plugins views), so it's informational, not an issue.
372
372
  {
373
- const { nativeFuseFs } = await import("../native/fusefs.js");
373
+ const { nativeFuseFs } = await import("../../native/fusefs.js");
374
374
  const addon = nativeFuseFs();
375
375
  checks.push(
376
376
  addon
@@ -392,7 +392,7 @@ export async function collectDoctorReport(opts: {
392
392
  // stale or the package is incomplete — caught at check time, not
393
393
  // mid-message when a backend assembles its system prompt.
394
394
  {
395
- const { loadSystemTemplate } = await import("./prompt/index.js");
395
+ const { loadSystemTemplate } = await import("../prompt/index.js");
396
396
  try {
397
397
  const sample = loadSystemTemplate("workspace");
398
398
  checks.push(
@@ -423,7 +423,7 @@ export async function collectDoctorReport(opts: {
423
423
  // is which is the difference between "why didn't my prompt update?"
424
424
  // and a one-line answer.
425
425
  {
426
- const { promptSeedReport } = await import("../util/workspace.js");
426
+ const { promptSeedReport } = await import("../../util/workspace.js");
427
427
  try {
428
428
  const { tracking, edited } = promptSeedReport();
429
429
  if (tracking.length + edited.length > 0) {
@@ -14,7 +14,7 @@
14
14
  * watches. Alerts about a dead frontend must travel over a LIVE one.
15
15
  */
16
16
 
17
- import { log, logWarn } from "./../util/log.js";
17
+ import { log, logWarn } from "../../util/log.js";
18
18
 
19
19
  let deliver: ((text: string) => Promise<void>) | null = null;
20
20
 
@@ -13,7 +13,7 @@
13
13
  * import-cheap for consumers that only need the id list.
14
14
  */
15
15
 
16
- import type { DoctorCheck } from "../doctor.js";
16
+ import type { DoctorCheck } from "../doctor/index.js";
17
17
  import type { ProvisionOutcome } from "./provision.js";
18
18
  import type { MempalaceSection } from "../../plugins/mempalace/provision.js";
19
19
  import type { PlaywrightSection } from "../../plugins/playwright/provision.js";
@@ -19,7 +19,7 @@ import {
19
19
  renderMetricsMessages,
20
20
  renderDoctorMessages,
21
21
  } from "../render.js";
22
- import { collectDoctorReport } from "../../../core/doctor.js";
22
+ import { collectDoctorReport } from "../../../core/doctor/index.js";
23
23
  import { getSoul } from "../../../core/soul/service.js";
24
24
  import { getMetrics, getTodayMetrics } from "../../../storage/metrics.js";
25
25
  import { handleAdminSubcommand } from "../admin.js";
@@ -14,7 +14,7 @@ import {
14
14
  DISCORD_SAFE_RESERVE,
15
15
  splitMessage,
16
16
  } from "./formatting.js";
17
- import type { DoctorReport } from "../../core/doctor.js";
17
+ import type { DoctorReport } from "../../core/doctor/index.js";
18
18
  import type { MeshPingResult } from "../../core/mesh/service.js";
19
19
  import type { BackendUsageEntry } from "../shared/plan-usage-report.js";
20
20
  import {
@@ -22,7 +22,7 @@ import type { ActionResult } from "../../../core/types.js";
22
22
  import { messagingHandlers, restoreScheduledMessages } from "./messaging.js";
23
23
  import { mediaHandlers } from "./media.js";
24
24
  import { chatInfoHandlers } from "./chat-info.js";
25
- import { moderationHandlers } from "./moderation.js";
25
+ import { moderationHandlers } from "./moderation/index.js";
26
26
  import type { TelegramActionContext, TelegramActionHandlers } from "./types.js";
27
27
 
28
28
  export { sendText } from "./shared.js";
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Chat-level ops — permissions, invite links, chat photo, unpin_all,
3
+ * leave_chat.
4
+ */
5
+
6
+ import { toPositiveId } from "../shared.js";
7
+ import { resolveMediaInput } from "../media.js";
8
+ import { toChatPermissions } from "./permissions.js";
9
+ import type { ModerationOps } from "./types.js";
10
+
11
+ export const chatOps: ModerationOps = {
12
+ set_permissions: async ({ body, chatId, ctx: { bot } }) => {
13
+ const raw = body.permissions;
14
+ if (!raw || typeof raw !== "object")
15
+ return { ok: false, error: "set_permissions: permissions required" };
16
+ await bot.api.setChatPermissions(
17
+ chatId,
18
+ toChatPermissions(raw as Record<string, boolean>),
19
+ );
20
+ return { ok: true };
21
+ },
22
+ create_invite_link: async ({ body, chatId, ctx: { bot } }) => {
23
+ const expireMin = Number(body.expire_minutes);
24
+ const link = await bot.api.createChatInviteLink(chatId, {
25
+ name: body.title ? String(body.title) : undefined,
26
+ expire_date:
27
+ Number.isFinite(expireMin) && expireMin > 0
28
+ ? Math.floor(Date.now() / 1000) + Math.round(expireMin * 60)
29
+ : undefined,
30
+ member_limit: toPositiveId(body.member_limit),
31
+ });
32
+ return { ok: true, invite_link: link.invite_link };
33
+ },
34
+ revoke_invite_link: async ({ body, chatId, ctx: { bot } }) => {
35
+ if (!body.link)
36
+ return { ok: false, error: "revoke_invite_link: link required" };
37
+ await bot.api.revokeChatInviteLink(chatId, String(body.link));
38
+ return { ok: true };
39
+ },
40
+ set_chat_photo: async ({ body, chatId, ctx: { bot, InputFileClass } }) => {
41
+ const resolved = resolveMediaInput(body, "set_chat_photo", InputFileClass);
42
+ if ("error" in resolved) return { ok: false, error: resolved.error };
43
+ if (typeof resolved.file === "string")
44
+ return {
45
+ ok: false,
46
+ error: "set_chat_photo needs a local file_path (no url/file_id)",
47
+ };
48
+ await bot.api.setChatPhoto(chatId, resolved.file);
49
+ return { ok: true };
50
+ },
51
+ delete_chat_photo: async ({ chatId, ctx: { bot } }) => {
52
+ await bot.api.deleteChatPhoto(chatId);
53
+ return { ok: true };
54
+ },
55
+ unpin_all: async ({ chatId, ctx: { bot } }) => {
56
+ await bot.api.unpinAllChatMessages(chatId);
57
+ return { ok: true };
58
+ },
59
+ leave_chat: async ({ chatId, ctx: { bot } }) => {
60
+ await bot.api.leaveChat(chatId);
61
+ return { ok: true };
62
+ },
63
+ };
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Moderation actions — the `moderate` tool's member/chat/topic operations,
3
+ * plus profile-photo lookup.
4
+ *
5
+ * Handlers call the Bot API directly and let Telegram's own errors surface
6
+ * (missing admin rights come back as descriptive 400s the model can read);
7
+ * pre-checking rights here would just race the real check.
8
+ *
9
+ * `moderate` dispatches on `body.op` through the table below:
10
+ * - `members` — ban / unban / mute / unmute / promote / demote /
11
+ * set_admin_title / *_join_request(s)
12
+ * - `chat` — set_permissions / *_invite_link / *_chat_photo /
13
+ * unpin_all / leave_chat
14
+ * - `topics` — create / edit / close / reopen / delete_topic
15
+ */
16
+
17
+ import { toPositiveId } from "../shared.js";
18
+ import type { TelegramActionHandlers } from "../types.js";
19
+ import { memberOps } from "./members.js";
20
+ import { chatOps } from "./chat.js";
21
+ import { topicOps } from "./topics.js";
22
+ import type { ModerationOps } from "./types.js";
23
+
24
+ // Null-prototype so an `op` of "toString" / "constructor" / etc. can't
25
+ // resolve an inherited Object.prototype method via `ops[op]`.
26
+ export const MODERATION_OPS: ModerationOps = Object.assign(
27
+ Object.create(null),
28
+ {
29
+ ...memberOps,
30
+ ...chatOps,
31
+ ...topicOps,
32
+ },
33
+ );
34
+
35
+ /** Ops that fail up front without a `user_id`. */
36
+ export const OPS_NEEDING_USER = [
37
+ "ban",
38
+ "unban",
39
+ "mute",
40
+ "unmute",
41
+ "promote",
42
+ "demote",
43
+ "set_admin_title",
44
+ "approve_join_request",
45
+ "decline_join_request",
46
+ ];
47
+
48
+ export const moderationHandlers: TelegramActionHandlers = {
49
+ moderate: async (body, chatId, ctx) => {
50
+ const op = String(body.op ?? "");
51
+ const userId = toPositiveId(body.user_id);
52
+ if (OPS_NEEDING_USER.includes(op) && userId === undefined)
53
+ return { ok: false, error: `${op}: user_id is required` };
54
+ const run = MODERATION_OPS[op];
55
+ if (!run) return { ok: false, error: `Unknown moderation op: ${op}` };
56
+ return run({ op, body, chatId, ctx, userId });
57
+ },
58
+
59
+ get_user_profile_photos: async (body, _chatId, { bot }) => {
60
+ const userId = toPositiveId(body.user_id);
61
+ if (userId === undefined)
62
+ return { ok: false, error: "user_id is required" };
63
+ const limit = toPositiveId(body.limit) ?? 5;
64
+ const photos = await bot.api.getUserProfilePhotos(userId, {
65
+ limit: Math.min(limit, 100),
66
+ });
67
+ return {
68
+ ok: true,
69
+ total: photos.total_count,
70
+ // Largest size of each photo; its file_id sends/downloads like media.
71
+ file_ids: photos.photos.map((sizes) => sizes[sizes.length - 1].file_id),
72
+ };
73
+ },
74
+ };
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Member ops — ban / unban / mute / unmute / promote / demote /
3
+ * set_admin_title, plus the join-request trio. Every op here except
4
+ * `list_join_requests` needs a `user_id`; the router has already checked
5
+ * that, so `userId!` is safe.
6
+ */
7
+
8
+ import { clearJoinRequest, listJoinRequests } from "../../join-requests.js";
9
+ import {
10
+ DEFAULT_ADMIN_RIGHTS,
11
+ FULL_MEMBER_PERMISSIONS,
12
+ untilDate,
13
+ } from "./permissions.js";
14
+ import type { ModerationOps } from "./types.js";
15
+
16
+ export const memberOps: ModerationOps = {
17
+ ban: async ({ body, chatId, ctx: { bot }, userId }) => {
18
+ await bot.api.banChatMember(chatId, userId!, {
19
+ until_date: untilDate(body.minutes),
20
+ revoke_messages: body.delete_messages === true || undefined,
21
+ });
22
+ return { ok: true };
23
+ },
24
+ unban: async ({ chatId, ctx: { bot }, userId }) => {
25
+ // only_if_banned keeps this from kicking a current member.
26
+ await bot.api.unbanChatMember(chatId, userId!, {
27
+ only_if_banned: true,
28
+ });
29
+ return { ok: true };
30
+ },
31
+ mute: async ({ body, chatId, ctx: { bot }, userId }) => {
32
+ await bot.api.restrictChatMember(
33
+ chatId,
34
+ userId!,
35
+ { can_send_messages: false },
36
+ { until_date: untilDate(body.minutes) },
37
+ );
38
+ return { ok: true };
39
+ },
40
+ unmute: async ({ chatId, ctx: { bot }, userId }) => {
41
+ await bot.api.restrictChatMember(chatId, userId!, FULL_MEMBER_PERMISSIONS);
42
+ return { ok: true };
43
+ },
44
+ promote: async ({ chatId, ctx: { bot }, userId }) => {
45
+ await bot.api.promoteChatMember(chatId, userId!, DEFAULT_ADMIN_RIGHTS);
46
+ return { ok: true };
47
+ },
48
+ demote: async ({ chatId, ctx: { bot }, userId }) => {
49
+ const revoked = Object.fromEntries(
50
+ Object.keys(DEFAULT_ADMIN_RIGHTS).map((k) => [k, false]),
51
+ );
52
+ await bot.api.promoteChatMember(chatId, userId!, revoked);
53
+ return { ok: true };
54
+ },
55
+ set_admin_title: async ({ body, chatId, ctx: { bot }, userId }) => {
56
+ await bot.api.setChatAdministratorCustomTitle(
57
+ chatId,
58
+ userId!,
59
+ String(body.title ?? ""),
60
+ );
61
+ return { ok: true };
62
+ },
63
+ approve_join_request: async ({ chatId, ctx: { bot }, userId }) => {
64
+ await bot.api.approveChatJoinRequest(chatId, userId!);
65
+ clearJoinRequest(chatId, userId!);
66
+ return { ok: true };
67
+ },
68
+ decline_join_request: async ({ chatId, ctx: { bot }, userId }) => {
69
+ await bot.api.declineChatJoinRequest(chatId, userId!);
70
+ clearJoinRequest(chatId, userId!);
71
+ return { ok: true };
72
+ },
73
+ list_join_requests: async ({ chatId }) => ({
74
+ ok: true,
75
+ requests: listJoinRequests(chatId),
76
+ }),
77
+ };
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Permission vocabulary shared by the member and chat ops.
3
+ */
4
+
5
+ import type { ChatPermissions } from "grammy/types";
6
+
7
+ /** Seconds-from-now → Telegram until_date, honouring "omit = forever". */
8
+ export function untilDate(minutes: unknown): number | undefined {
9
+ const n = Number(minutes);
10
+ if (!Number.isFinite(n) || n <= 0) return undefined;
11
+ return Math.floor(Date.now() / 1000) + Math.round(n * 60);
12
+ }
13
+
14
+ /**
15
+ * Friendly permission names → ChatPermissions. `send_media` fans out to all
16
+ * the per-kind media flags; raw `can_*` keys pass through untouched so the
17
+ * full API surface stays reachable.
18
+ */
19
+ export function toChatPermissions(
20
+ raw: Record<string, boolean>,
21
+ ): ChatPermissions {
22
+ const out: Record<string, boolean> = {};
23
+ const alias: Record<string, string[]> = {
24
+ send_messages: ["can_send_messages"],
25
+ send_media: [
26
+ "can_send_audios",
27
+ "can_send_documents",
28
+ "can_send_photos",
29
+ "can_send_videos",
30
+ "can_send_video_notes",
31
+ "can_send_voice_notes",
32
+ ],
33
+ send_polls: ["can_send_polls"],
34
+ send_other: ["can_send_other_messages"],
35
+ web_previews: ["can_add_web_page_previews"],
36
+ change_info: ["can_change_info"],
37
+ invite_users: ["can_invite_users"],
38
+ pin_messages: ["can_pin_messages"],
39
+ manage_topics: ["can_manage_topics"],
40
+ };
41
+ for (const [key, value] of Object.entries(raw)) {
42
+ if (typeof value !== "boolean") continue;
43
+ for (const target of alias[key] ?? (key.startsWith("can_") ? [key] : [])) {
44
+ out[target] = value;
45
+ }
46
+ }
47
+ return out;
48
+ }
49
+
50
+ /** Everything a mute takes away; unmute grants it back. */
51
+ export const FULL_MEMBER_PERMISSIONS: ChatPermissions = toChatPermissions({
52
+ send_messages: true,
53
+ send_media: true,
54
+ send_polls: true,
55
+ send_other: true,
56
+ web_previews: true,
57
+ });
58
+
59
+ /** The workaday admin kit `promote` grants; demote sets all of these false. */
60
+ export const DEFAULT_ADMIN_RIGHTS = {
61
+ can_manage_chat: true,
62
+ can_delete_messages: true,
63
+ can_restrict_members: true,
64
+ can_pin_messages: true,
65
+ can_invite_users: true,
66
+ can_change_info: true,
67
+ can_manage_topics: true,
68
+ can_manage_video_chats: true,
69
+ };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Forum-topic ops — create / edit / close / reopen / delete.
3
+ */
4
+
5
+ import type { Bot } from "grammy";
6
+ import { toPositiveId } from "../shared.js";
7
+ import type { ModerationOp, ModerationOps } from "./types.js";
8
+
9
+ /** An op on an existing topic: validates `thread_id`, then runs `call`. */
10
+ function topicOp(
11
+ call: (
12
+ api: Bot["api"],
13
+ chatId: number,
14
+ threadId: number,
15
+ body: Record<string, unknown>,
16
+ ) => Promise<unknown>,
17
+ ): ModerationOp {
18
+ return async ({ op, body, chatId, ctx: { bot } }) => {
19
+ const threadId = toPositiveId(body.thread_id);
20
+ if (threadId === undefined)
21
+ return { ok: false, error: `${op}: thread_id required` };
22
+ await call(bot.api, chatId, threadId, body);
23
+ return { ok: true };
24
+ };
25
+ }
26
+
27
+ export const topicOps: ModerationOps = {
28
+ create_topic: async ({ body, chatId, ctx: { bot } }) => {
29
+ if (!body.title)
30
+ return { ok: false, error: "create_topic: title required" };
31
+ const topic = await bot.api.createForumTopic(chatId, String(body.title));
32
+ return { ok: true, thread_id: topic.message_thread_id };
33
+ },
34
+ edit_topic: topicOp((api, chatId, threadId, body) =>
35
+ api.editForumTopic(chatId, threadId, {
36
+ name: body.title ? String(body.title) : undefined,
37
+ }),
38
+ ),
39
+ close_topic: topicOp((api, chatId, threadId) =>
40
+ api.closeForumTopic(chatId, threadId),
41
+ ),
42
+ reopen_topic: topicOp((api, chatId, threadId) =>
43
+ api.reopenForumTopic(chatId, threadId),
44
+ ),
45
+ delete_topic: topicOp((api, chatId, threadId) =>
46
+ api.deleteForumTopic(chatId, threadId),
47
+ ),
48
+ };
@@ -0,0 +1,16 @@
1
+ import type { ActionResult } from "../../../../core/types.js";
2
+ import type { TelegramActionContext } from "../types.js";
3
+
4
+ /** One `moderate` call, pre-parsed: the op name, the raw body, and the
5
+ * `user_id` the router already validated for ops that need one. */
6
+ type ModerationRequest = {
7
+ op: string;
8
+ body: Record<string, unknown>;
9
+ chatId: number;
10
+ ctx: TelegramActionContext;
11
+ userId: number | undefined;
12
+ };
13
+
14
+ export type ModerationOp = (req: ModerationRequest) => Promise<ActionResult>;
15
+
16
+ export type ModerationOps = Record<string, ModerationOp>;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * `/admin cron` / `pulse` — the background-job subcommands.
3
+ */
4
+
5
+ import type { Bot, Context } from "grammy";
6
+ import { escapeHtml } from "../formatting.js";
7
+ import {
8
+ getAllCronJobs,
9
+ describeSchedule,
10
+ nextRunAt,
11
+ } from "../../../storage/cron.js";
12
+ import { getPulseStatus } from "../../../core/background/pulse.js";
13
+ import { replyHtmlChunked } from "./chunked-reply.js";
14
+
15
+ export async function replyCronJobs(ctx: Context): Promise<void> {
16
+ const jobs = getAllCronJobs();
17
+ if (jobs.length === 0) {
18
+ await ctx.reply("No cron jobs.");
19
+ return;
20
+ }
21
+ const lines = jobs.map((j) => {
22
+ const nextMs = nextRunAt(j);
23
+ const last = j.lastRunAt
24
+ ? new Date(j.lastRunAt).toISOString().slice(0, 16).replace("T", " ")
25
+ : "never";
26
+ const next = nextMs
27
+ ? new Date(nextMs).toISOString().slice(0, 16).replace("T", " ")
28
+ : "?";
29
+ return `${j.enabled ? "✓" : "✗"} <b>${escapeHtml(j.name)}</b>\n <code>${escapeHtml(describeSchedule(j))}</code> | ${j.type} | runs: ${j.runCount} | last: ${last} | next: ${next}`;
30
+ });
31
+ await replyHtmlChunked(
32
+ ctx,
33
+ `<b>Cron Jobs (${jobs.length})</b>\n\n` + lines.join("\n\n"),
34
+ );
35
+ }
36
+
37
+ export async function replyPulseStatus(
38
+ ctx: Context,
39
+ _rest: string[],
40
+ bot: Bot,
41
+ ): Promise<void> {
42
+ const chats = getPulseStatus();
43
+ if (chats.length === 0) {
44
+ await ctx.reply("No pulse chats.");
45
+ return;
46
+ }
47
+ const lines = await Promise.all(
48
+ chats.map(async (p) => {
49
+ let title = p.chatId;
50
+ try {
51
+ const id = parseInt(p.chatId, 10);
52
+ if (!isNaN(id)) {
53
+ const chat = await bot.api.getChat(id);
54
+ title = "title" in chat ? (chat.title ?? p.chatId) : p.chatId;
55
+ }
56
+ } catch {
57
+ /* skip */
58
+ }
59
+ return `${p.enabled ? "✓" : "✗"} ${escapeHtml(title)}`;
60
+ }),
61
+ );
62
+ await replyHtmlChunked(
63
+ ctx,
64
+ `<b>Pulse (${chats.length})</b>\n\n` + lines.join("\n"),
65
+ );
66
+ }
@@ -0,0 +1,20 @@
1
+ import type { Context } from "grammy";
2
+ import { splitMessage } from "../formatting.js";
3
+ import { TELEGRAM_MAX_TEXT } from "../actions/types.js";
4
+
5
+ /**
6
+ * Reply with an HTML listing, split across messages when it outgrows
7
+ * Telegram's 4096-char cap. The per-item listings (chats, cron, pulse)
8
+ * scale with the daemon's chat count, and a single oversized
9
+ * `ctx.reply` fails the whole command with 400 "message is too long".
10
+ * Entries are `\n\n`-separated and each carries balanced tags, so the
11
+ * paragraph-first splitter never cuts through markup.
12
+ */
13
+ export async function replyHtmlChunked(
14
+ ctx: Context,
15
+ text: string,
16
+ ): Promise<void> {
17
+ for (const chunk of splitMessage(text, TELEGRAM_MAX_TEXT)) {
18
+ await ctx.reply(chunk, { parse_mode: "HTML" });
19
+ }
20
+ }