talon-agent 4.3.1 → 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/app.ts +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 +3 -3
- package/src/cli/doctor.ts +1 -1
- package/src/cli/skill.ts +2 -2
- package/src/core/agent-runtime/backend-registry.ts +1 -1
- package/src/core/auth/expiry-monitor.ts +1 -1
- package/src/core/background/cron-spec.ts +1 -1
- package/src/core/background/cron.ts +1 -1
- package/src/core/background/heartbeat/agent.ts +1 -1
- package/src/core/background/triggers/command.ts +1 -1
- package/src/core/background/triggers/exit.ts +1 -1
- package/src/core/background/triggers/output.ts +1 -1
- package/src/core/background/triggers/resume.ts +1 -1
- package/src/core/background/triggers/spawn.ts +1 -1
- package/src/core/{doctor.ts → doctor/index.ts} +11 -11
- package/src/core/engine/gateway-actions/cron.ts +2 -2
- package/src/core/engine/gateway-actions/goals.ts +2 -2
- package/src/core/engine/gateway-actions/scripts.ts +1 -1
- package/src/core/engine/gateway-actions/skills.ts +1 -1
- package/src/core/engine/gateway-actions/triggers.ts +2 -2
- package/src/core/engine/gateway-actions/{shared.ts → validation.ts} +2 -2
- package/src/core/{notify.ts → frontend-runtime/admin-notify.ts} +1 -1
- package/src/core/plugin/native-runtimes.ts +1 -1
- package/src/core/prompt/assemble.ts +2 -2
- package/src/core/scripts/runner.ts +1 -1
- package/src/core/soul/forgetting.ts +2 -2
- package/src/core/soul/salience.ts +8 -4
- package/src/frontend/discord/actions/{shared.ts → buttons.ts} +3 -56
- package/src/frontend/discord/actions/channels.ts +34 -0
- package/src/frontend/discord/actions/chat-info.ts +1 -1
- package/src/frontend/discord/actions/index.ts +1 -1
- package/src/frontend/discord/actions/media.ts +1 -1
- package/src/frontend/discord/actions/messaging.ts +4 -2
- package/src/frontend/discord/actions/try-action.ts +24 -0
- package/src/frontend/discord/admin.ts +2 -2
- package/src/frontend/discord/callbacks/components/settings.ts +1 -1
- package/src/frontend/discord/callbacks/modals.ts +1 -1
- package/src/frontend/discord/commands/admin.ts +3 -3
- package/src/frontend/discord/commands/info.ts +2 -2
- package/src/frontend/discord/commands/{shared.ts → interaction.ts} +2 -1
- package/src/frontend/discord/commands/router.ts +1 -1
- package/src/frontend/discord/commands/session.ts +2 -2
- package/src/frontend/discord/commands/settings.ts +2 -2
- package/src/frontend/discord/{helpers.ts → render.ts} +3 -2
- package/src/frontend/native/extensions.ts +1 -1
- package/src/frontend/teams/chat-discovery.ts +69 -0
- package/src/frontend/teams/commands.ts +141 -0
- package/src/frontend/teams/index.ts +24 -350
- package/src/frontend/teams/outbound.ts +44 -0
- package/src/frontend/teams/poll.ts +101 -0
- package/src/frontend/teams/runtime.ts +51 -0
- package/src/frontend/teams/turn.ts +97 -0
- package/src/frontend/telegram/actions/index.ts +1 -1
- package/src/frontend/telegram/actions/messaging.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/actions/shared.ts +4 -4
- 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/telegram/sticker-library.ts +2 -2
- 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/actions/account.ts +3 -6
- package/src/frontend/whatsapp/actions/chat-info.ts +2 -1
- package/src/frontend/whatsapp/actions/coerce.ts +30 -0
- package/src/frontend/whatsapp/actions/history.ts +1 -1
- package/src/frontend/whatsapp/actions/media-upload.ts +82 -0
- package/src/frontend/whatsapp/actions/media.ts +3 -8
- package/src/frontend/whatsapp/actions/messaging.ts +2 -7
- package/src/frontend/whatsapp/actions/moderation.ts +2 -1
- package/src/frontend/whatsapp/actions/{shared.ts → send.ts} +4 -124
- package/src/frontend/whatsapp/actions/try-action.ts +24 -0
- package/src/frontend/whatsapp/commands.ts +1 -1
- package/src/frontend/whatsapp/connection.ts +1 -1
- package/src/frontend/whatsapp/inbound.ts +1 -1
- package/src/frontend/whatsapp/index.ts +2 -2
- 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/storage/repositories/cron-repo.ts +1 -1
- package/src/storage/repositories/goals-repo.ts +1 -1
- package/src/storage/repositories/scripts-repo.ts +1 -1
- package/src/storage/repositories/triggers-repo.ts +1 -1
- package/src/storage/sql/schema.sql +1 -1
- package/src/storage/sql/statements.generated.ts +2 -2
- package/src/storage/sql/triggers.sql +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/src/storage/{cron-store.ts → cron.ts} +0 -0
- /package/src/storage/{goal-store.ts → goals.ts} +0 -0
- /package/src/storage/{scheduled-store.ts → scheduled.ts} +0 -0
- /package/src/storage/{script-store.ts → scripts.ts} +0 -0
- /package/src/storage/{skill-store.ts → skills.ts} +0 -0
- /package/src/storage/{sticker-store.ts → stickers.ts} +0 -0
- /package/src/storage/{trigger-store.ts → triggers.ts} +0 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Teams model turn — one inbound chat message becomes one `execute()` call.
|
|
3
|
+
* Progress is logged; assistant text is delivered through the webhook.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { log, logError } from "../../util/log.js";
|
|
7
|
+
import { deriveNumericChatId } from "../../util/chat-id.js";
|
|
8
|
+
import {
|
|
9
|
+
toolInputToRecord,
|
|
10
|
+
type AgentEvent,
|
|
11
|
+
} from "../../core/agent-runtime/events.js";
|
|
12
|
+
import { execute } from "../../core/engine/dispatcher.js";
|
|
13
|
+
import { postToTeams } from "./actions.js";
|
|
14
|
+
import type { ChatMessage } from "./graph.js";
|
|
15
|
+
import type { TeamsRuntime } from "./runtime.js";
|
|
16
|
+
|
|
17
|
+
function logToolCall(event: Extract<AgentEvent, { type: "tool_call" }>): void {
|
|
18
|
+
const input = toolInputToRecord(event.name, event.input);
|
|
19
|
+
const detail = (input.description ??
|
|
20
|
+
input.command ??
|
|
21
|
+
input.action ??
|
|
22
|
+
input.query ??
|
|
23
|
+
input.url ??
|
|
24
|
+
input.name ??
|
|
25
|
+
"") as string;
|
|
26
|
+
log(
|
|
27
|
+
"teams",
|
|
28
|
+
` tool: ${event.name}${detail ? ` — ${String(detail).slice(0, 100)}` : ""}`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Deliver assistant text (progress text before tool calls AND the
|
|
33
|
+
// end-of-turn trailing-text fallback) to the Teams chat. Without this,
|
|
34
|
+
// prose-only assistant turns would be silently dropped — same scratchpad
|
|
35
|
+
// bug Telegram hit.
|
|
36
|
+
async function deliverAssistantText(
|
|
37
|
+
runtime: TeamsRuntime,
|
|
38
|
+
numericChatId: number,
|
|
39
|
+
text: string,
|
|
40
|
+
): Promise<void> {
|
|
41
|
+
if (!text.trim()) return;
|
|
42
|
+
try {
|
|
43
|
+
await postToTeams(runtime.webhookUrl, text);
|
|
44
|
+
runtime.gateway.incrementMessages(numericChatId);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
// Post failures are swallowed (logged, not rethrown) — preserves the old
|
|
47
|
+
// `onTextBlock` semantics where Teams never propagated delivery errors
|
|
48
|
+
// back to the backend. Returning normally lets the dispatcher resolve
|
|
49
|
+
// the ack.
|
|
50
|
+
logError(
|
|
51
|
+
"teams",
|
|
52
|
+
`onEvent postToTeams failed: ${err instanceof Error ? err.message : err}`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function runTurn(
|
|
58
|
+
runtime: TeamsRuntime,
|
|
59
|
+
msg: ChatMessage,
|
|
60
|
+
talonChatId: string,
|
|
61
|
+
): void {
|
|
62
|
+
const numericChatId = deriveNumericChatId(msg.chatId);
|
|
63
|
+
execute({
|
|
64
|
+
chatId: talonChatId,
|
|
65
|
+
numericChatId,
|
|
66
|
+
prompt: `[${msg.senderName}]: ${msg.text}`,
|
|
67
|
+
senderName: msg.senderName,
|
|
68
|
+
isGroup: true,
|
|
69
|
+
source: "message",
|
|
70
|
+
onEvent: async (event) => {
|
|
71
|
+
switch (event.type) {
|
|
72
|
+
case "text_delta":
|
|
73
|
+
log("teams", ` phase: text`);
|
|
74
|
+
break;
|
|
75
|
+
case "reasoning":
|
|
76
|
+
log("teams", ` phase: thinking`);
|
|
77
|
+
break;
|
|
78
|
+
case "tool_call":
|
|
79
|
+
logToolCall(event);
|
|
80
|
+
break;
|
|
81
|
+
case "assistant_message":
|
|
82
|
+
await deliverAssistantText(runtime, numericChatId, event.text);
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
// No fallback delivery — turns without end_turn / send_message are
|
|
88
|
+
// intentional silent ends. Trailing prose without a tool call is
|
|
89
|
+
// scratchpad and dropped; the SDK handler emits a
|
|
90
|
+
// `scratchpad.trailing_text_dropped` metric on those.
|
|
91
|
+
.catch((err) => {
|
|
92
|
+
logError(
|
|
93
|
+
"teams",
|
|
94
|
+
`execute failed: ${err instanceof Error ? err.message : err}`,
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
@@ -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>;
|
|
@@ -39,7 +39,7 @@ export function replyParamsFor(
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/** Delivery modifiers shared by every outbound send. */
|
|
42
|
-
export type
|
|
42
|
+
export type ExtraSendOptions = {
|
|
43
43
|
message_thread_id?: number;
|
|
44
44
|
disable_notification?: boolean;
|
|
45
45
|
protect_content?: boolean;
|
|
@@ -54,7 +54,7 @@ export type ExtraSendOpts = {
|
|
|
54
54
|
export function sendOpts(
|
|
55
55
|
body: Record<string, unknown>,
|
|
56
56
|
chatId: number,
|
|
57
|
-
):
|
|
57
|
+
): ExtraSendOptions {
|
|
58
58
|
return {
|
|
59
59
|
message_thread_id: resolveThreadId(body, chatId),
|
|
60
60
|
disable_notification: body.silent === true || undefined,
|
|
@@ -126,7 +126,7 @@ export async function sendText(
|
|
|
126
126
|
replyMarkup?: NonNullable<
|
|
127
127
|
Parameters<Bot["api"]["sendRichMessage"]>[2]
|
|
128
128
|
>["reply_markup"],
|
|
129
|
-
extra?:
|
|
129
|
+
extra?: ExtraSendOptions,
|
|
130
130
|
): Promise<number> {
|
|
131
131
|
if (text.length > TELEGRAM_MAX_TEXT) {
|
|
132
132
|
throw new Error(
|
|
@@ -137,7 +137,7 @@ export async function sendText(
|
|
|
137
137
|
// Default to the chat's ambient forum topic so every text path — commands,
|
|
138
138
|
// menus, scheduled replays — stays in the topic the conversation is in.
|
|
139
139
|
// An explicit `extra` (from sendOpts) already resolved this.
|
|
140
|
-
const opts:
|
|
140
|
+
const opts: ExtraSendOptions = extra ?? {
|
|
141
141
|
message_thread_id: ambientThreadId(chatId),
|
|
142
142
|
};
|
|
143
143
|
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/admin stats` / `errors` / `logs` / `daily` — the daemon-health
|
|
3
|
+
* subcommands.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Context } from "grammy";
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { files, dirs } from "../../../util/paths.js";
|
|
9
|
+
import { tailFile } from "../../../util/tail-file.js";
|
|
10
|
+
import { escapeHtml } from "../formatting.js";
|
|
11
|
+
import { getAllSessions } from "../../../storage/sessions.js";
|
|
12
|
+
import { todayLogDate } from "../../../storage/daily-log.js";
|
|
13
|
+
import { getActiveCount } from "../../../core/engine/dispatcher.js";
|
|
14
|
+
import { getHealthStatus, getRecentErrors } from "../../../util/watchdog.js";
|
|
15
|
+
import { formatDuration } from "../helpers/index.js";
|
|
16
|
+
|
|
17
|
+
export async function replyStats(ctx: Context): Promise<void> {
|
|
18
|
+
const h = getHealthStatus();
|
|
19
|
+
const sessions = getAllSessions();
|
|
20
|
+
const turns = sessions.reduce((s, x) => s + x.info.turns, 0);
|
|
21
|
+
const mem = process.memoryUsage();
|
|
22
|
+
await ctx.reply(
|
|
23
|
+
[
|
|
24
|
+
`<b>🦅 Talon Stats</b>`,
|
|
25
|
+
"",
|
|
26
|
+
`<b>Uptime:</b> ${formatDuration(h.uptimeMs)}`,
|
|
27
|
+
`<b>Messages:</b> ${h.totalMessagesProcessed}`,
|
|
28
|
+
`<b>Sessions:</b> ${sessions.length}`,
|
|
29
|
+
`<b>Turns:</b> ${turns}`,
|
|
30
|
+
`<b>Last active:</b> ${h.msSinceLastMessage < 60000 ? "now" : formatDuration(h.msSinceLastMessage) + " ago"}`,
|
|
31
|
+
"",
|
|
32
|
+
`<b>Memory:</b> ${(mem.heapUsed / 1024 / 1024).toFixed(1)}MB heap / ${(mem.rss / 1024 / 1024).toFixed(1)}MB rss`,
|
|
33
|
+
`<b>Queue:</b> ${getActiveCount()}`,
|
|
34
|
+
`<b>Errors:</b> ${h.recentErrorCount}`,
|
|
35
|
+
].join("\n"),
|
|
36
|
+
{ parse_mode: "HTML" },
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function replyRecentErrors(ctx: Context): Promise<void> {
|
|
41
|
+
const errors = getRecentErrors(5);
|
|
42
|
+
if (errors.length === 0) {
|
|
43
|
+
await ctx.reply("No recent errors.");
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const lines = errors.map(
|
|
47
|
+
(e) =>
|
|
48
|
+
`<code>[${new Date(e.timestamp).toISOString().slice(11, 19)}]</code> ${escapeHtml(e.message.slice(0, 200))}`,
|
|
49
|
+
);
|
|
50
|
+
await ctx.reply(
|
|
51
|
+
`<b>Recent Errors (${errors.length})</b>\n\n` + lines.join("\n\n"),
|
|
52
|
+
{ parse_mode: "HTML" },
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function replyLogTail(ctx: Context): Promise<void> {
|
|
57
|
+
const logPath = files.log;
|
|
58
|
+
try {
|
|
59
|
+
const lines = tailFile(logPath);
|
|
60
|
+
await ctx.reply(`<pre>${escapeHtml(lines.slice(0, 3800))}</pre>`, {
|
|
61
|
+
parse_mode: "HTML",
|
|
62
|
+
});
|
|
63
|
+
} catch {
|
|
64
|
+
await ctx.reply(`Could not read ${logPath}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function replyDailyLog(ctx: Context): Promise<void> {
|
|
69
|
+
const today = todayLogDate();
|
|
70
|
+
const logPath = `${dirs.logs}/${today}.md`;
|
|
71
|
+
try {
|
|
72
|
+
const content = readFileSync(logPath, "utf-8");
|
|
73
|
+
const lines = content.trim().split("\n").slice(-30).join("\n");
|
|
74
|
+
await ctx.reply(
|
|
75
|
+
`<b>Daily log (${today})</b>\n\n<pre>${escapeHtml(lines.slice(0, 3800))}</pre>`,
|
|
76
|
+
{ parse_mode: "HTML" },
|
|
77
|
+
);
|
|
78
|
+
} catch {
|
|
79
|
+
await ctx.reply(`No daily log for ${today}.`);
|
|
80
|
+
}
|
|
81
|
+
}
|