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.
- package/package.json +1 -1
- package/src/backend/claude-sdk/doctor.ts +1 -1
- package/src/backend/codex/doctor.ts +1 -1
- package/src/backend/openai-agents/doctor.ts +1 -1
- package/src/bootstrap.ts +1 -1
- package/src/cli/doctor.ts +1 -1
- package/src/core/agent-runtime/backend-registry.ts +1 -1
- package/src/core/auth/expiry-monitor.ts +1 -1
- package/src/core/{doctor.ts → doctor/index.ts} +11 -11
- package/src/core/{notify.ts → frontend-runtime/admin-notify.ts} +1 -1
- package/src/core/plugin/native-runtimes.ts +1 -1
- package/src/frontend/discord/commands/admin.ts +1 -1
- package/src/frontend/discord/render.ts +1 -1
- package/src/frontend/telegram/actions/index.ts +1 -1
- package/src/frontend/telegram/actions/moderation/chat.ts +63 -0
- package/src/frontend/telegram/actions/moderation/index.ts +74 -0
- package/src/frontend/telegram/actions/moderation/members.ts +77 -0
- package/src/frontend/telegram/actions/moderation/permissions.ts +69 -0
- package/src/frontend/telegram/actions/moderation/topics.ts +48 -0
- package/src/frontend/telegram/actions/moderation/types.ts +16 -0
- package/src/frontend/telegram/admin/background.ts +66 -0
- package/src/frontend/telegram/admin/chunked-reply.ts +20 -0
- package/src/frontend/telegram/admin/health.ts +81 -0
- package/src/frontend/telegram/admin/sessions.ts +105 -0
- package/src/frontend/telegram/admin.ts +56 -261
- package/src/frontend/telegram/callbacks/model/backend.ts +156 -0
- package/src/frontend/telegram/callbacks/model/control.ts +29 -0
- package/src/frontend/telegram/callbacks/model/nav.ts +54 -0
- package/src/frontend/telegram/callbacks/model/select.ts +100 -0
- package/src/frontend/telegram/callbacks/model/types.ts +39 -0
- package/src/frontend/telegram/callbacks/model/views.ts +116 -0
- package/src/frontend/telegram/callbacks/model.ts +61 -331
- package/src/frontend/telegram/commands/admin.ts +108 -77
- package/src/frontend/telegram/commands/settings.ts +65 -43
- package/src/frontend/telegram/commands/whatsapp-pairing.ts +1 -1
- package/src/frontend/telegram/helpers/diagnostics.ts +1 -1
- package/src/frontend/telegram/middleware.ts +166 -201
- package/src/frontend/terminal/builtins/context.ts +184 -0
- package/src/frontend/terminal/builtins/help.ts +35 -0
- package/src/frontend/terminal/builtins/index.ts +23 -0
- package/src/frontend/terminal/builtins/model.ts +198 -0
- package/src/frontend/terminal/builtins/session.ts +101 -0
- package/src/frontend/terminal/builtins/status.ts +202 -0
- package/src/frontend/terminal/command-registry.ts +80 -0
- package/src/frontend/terminal/commands.ts +11 -674
- package/src/frontend/terminal/input/history.ts +64 -0
- package/src/frontend/terminal/input/keys.ts +143 -0
- package/src/frontend/terminal/input/parts.ts +33 -0
- package/src/frontend/terminal/input/paste.ts +53 -0
- package/src/frontend/terminal/input/render.ts +42 -0
- package/src/frontend/terminal/input/state.ts +60 -0
- package/src/frontend/terminal/input.ts +27 -342
- package/src/frontend/whatsapp/connection.ts +1 -1
- package/src/frontend/whatsapp/index.ts +1 -1
- package/src/frontend/whatsapp/pairing-service.ts +1 -1
- package/src/plugins/github/provision.ts +1 -1
- package/src/plugins/mempalace/provision.ts +1 -1
- package/src/plugins/playwright/provision.ts +1 -1
- package/src/frontend/telegram/actions/moderation.ts +0 -255
- /package/src/core/{doctor-types.ts → doctor/types.ts} +0 -0
- /package/src/core/{pairing-broker.ts → frontend-runtime/pairing-broker.ts} +0 -0
package/package.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import type {
|
|
8
8
|
DoctorCheck,
|
|
9
9
|
DoctorConfigSlice,
|
|
10
|
-
} from "../../core/doctor
|
|
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";
|
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
|
|
33
|
+
import type { DoctorCheck, DoctorConfigSlice } from "../doctor/types.js";
|
|
34
34
|
import type { TalonConfig } from "../../util/config.js";
|
|
35
35
|
|
|
36
36
|
// ── Types ───────────────────────────────────────────────────────────────────
|
|
@@ -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 "
|
|
17
|
-
import { dirs } from "
|
|
18
|
-
import { getBackend, listBackends } from "
|
|
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 "./
|
|
24
|
+
} from "./types.js";
|
|
25
25
|
|
|
26
26
|
export type {
|
|
27
27
|
DoctorCheck,
|
|
28
28
|
DoctorConfigSlice,
|
|
29
29
|
DoctorReport,
|
|
30
30
|
NativeModuleCheck,
|
|
31
|
-
} from "./
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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) {
|
|
@@ -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
|
+
}
|