talon-agent 4.3.1 → 4.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/app.ts +1 -1
- package/src/bootstrap.ts +2 -2
- package/src/cli/skill.ts +2 -2
- 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/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/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 +2 -2
- 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} +2 -1
- 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/messaging.ts +1 -1
- package/src/frontend/telegram/actions/shared.ts +4 -4
- package/src/frontend/telegram/admin.ts +1 -1
- package/src/frontend/telegram/sticker-library.ts +2 -2
- 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/inbound.ts +1 -1
- package/src/frontend/whatsapp/index.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/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,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Teams poll loop — Graph API chat polling is the whole receive side (no
|
|
3
|
+
* Bot Framework, no inbound endpoint). Each tick fetches the newest
|
|
4
|
+
* messages, cuts at the last one seen, and hands the rest — oldest first —
|
|
5
|
+
* to the slash-command router or a model turn.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { log, logError } from "../../util/log.js";
|
|
9
|
+
import { handleSlashCommand } from "./commands.js";
|
|
10
|
+
import type { ChatMessage } from "./graph.js";
|
|
11
|
+
import type { TeamsRuntime } from "./runtime.js";
|
|
12
|
+
import { runTurn } from "./turn.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Messages arrive newest first; everything before `lastSeenId` is new. IDs
|
|
16
|
+
* are opaque strings, so this is a cut at the seen id, not a comparison.
|
|
17
|
+
*/
|
|
18
|
+
export function selectNewMessages(
|
|
19
|
+
messages: ChatMessage[],
|
|
20
|
+
lastSeenId: string | null,
|
|
21
|
+
): ChatMessage[] {
|
|
22
|
+
const fresh: ChatMessage[] = [];
|
|
23
|
+
for (const msg of messages) {
|
|
24
|
+
if (lastSeenId && msg.id === lastSeenId) break;
|
|
25
|
+
fresh.push(msg);
|
|
26
|
+
}
|
|
27
|
+
return fresh;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Skip bot/workflow messages by display name (echo loop prevention).
|
|
31
|
+
// We do NOT filter by user ID — the authenticated user also sends
|
|
32
|
+
// real messages that Talon should respond to.
|
|
33
|
+
function isBotEcho(runtime: TeamsRuntime, msg: ChatMessage): boolean {
|
|
34
|
+
return (
|
|
35
|
+
!!runtime.botDisplayName &&
|
|
36
|
+
msg.senderName.toLowerCase() === runtime.botDisplayName.toLowerCase()
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function handleMessage(
|
|
41
|
+
runtime: TeamsRuntime,
|
|
42
|
+
msg: ChatMessage,
|
|
43
|
+
): Promise<void> {
|
|
44
|
+
const talonChatId = `teams_chat_${msg.chatId}`;
|
|
45
|
+
if (await handleSlashCommand(runtime, msg, talonChatId)) return;
|
|
46
|
+
|
|
47
|
+
log(
|
|
48
|
+
"teams",
|
|
49
|
+
`[${msg.senderName}]: ${msg.text.slice(0, 80)}${msg.text.length > 80 ? "..." : ""}`,
|
|
50
|
+
);
|
|
51
|
+
runTurn(runtime, msg, talonChatId);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function poll(runtime: TeamsRuntime, chatId: string): Promise<void> {
|
|
55
|
+
if (runtime.polling) return;
|
|
56
|
+
runtime.polling = true;
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
if (!runtime.graphClient) return;
|
|
60
|
+
const messages = await runtime.graphClient.getChatMessages(chatId, 20);
|
|
61
|
+
const newMessages = selectNewMessages(messages, runtime.lastSeenMessageId);
|
|
62
|
+
|
|
63
|
+
if (newMessages.length > 0) {
|
|
64
|
+
runtime.lastSeenMessageId = newMessages[0].id;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Process in chronological order (oldest first)
|
|
68
|
+
for (const msg of newMessages.reverse()) {
|
|
69
|
+
if (!msg.text.trim()) continue;
|
|
70
|
+
if (msg.edited) continue;
|
|
71
|
+
if (isBotEcho(runtime, msg)) continue;
|
|
72
|
+
await handleMessage(runtime, msg);
|
|
73
|
+
}
|
|
74
|
+
} catch (err) {
|
|
75
|
+
logError(
|
|
76
|
+
"teams",
|
|
77
|
+
`Poll error: ${err instanceof Error ? err.message : err}`,
|
|
78
|
+
);
|
|
79
|
+
} finally {
|
|
80
|
+
runtime.polling = false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Initial poll, then the interval; the timer lives on the runtime so stop can clear it. */
|
|
85
|
+
export async function startPolling(
|
|
86
|
+
runtime: TeamsRuntime,
|
|
87
|
+
chatId: string,
|
|
88
|
+
): Promise<void> {
|
|
89
|
+
await poll(runtime, chatId);
|
|
90
|
+
runtime.pollTimer = setInterval(
|
|
91
|
+
() => void poll(runtime, chatId),
|
|
92
|
+
runtime.pollIntervalMs,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function stopPolling(runtime: TeamsRuntime): void {
|
|
97
|
+
if (runtime.pollTimer) {
|
|
98
|
+
clearInterval(runtime.pollTimer);
|
|
99
|
+
runtime.pollTimer = null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Teams frontend runtime — the state every module shares.
|
|
3
|
+
*
|
|
4
|
+
* `createTeamsFrontend` used to hold all of this as closure variables, with
|
|
5
|
+
* the poll loop and the slash commands nested inside `.start`. It is now one
|
|
6
|
+
* explicit object, constructed once, that each module (chat-discovery,
|
|
7
|
+
* poll, commands, turn, outbound) takes as its first parameter.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { TalonConfig } from "../../util/config.js";
|
|
11
|
+
import type { Gateway } from "../../core/engine/gateway.js";
|
|
12
|
+
import type { GraphClient } from "./graph.js";
|
|
13
|
+
|
|
14
|
+
export type TeamsRuntime = {
|
|
15
|
+
readonly config: TalonConfig;
|
|
16
|
+
readonly gateway: Gateway;
|
|
17
|
+
/** Power Automate workflow webhook URL every outbound card is POSTed to. */
|
|
18
|
+
readonly webhookUrl: string;
|
|
19
|
+
/** Display name the workflow posts under — its messages are skipped. */
|
|
20
|
+
readonly botDisplayName: string;
|
|
21
|
+
readonly pollIntervalMs: number;
|
|
22
|
+
/** Optional topic substring used to pick the chat on first run. */
|
|
23
|
+
readonly configChatTopic: string;
|
|
24
|
+
graphClient: GraphClient | null;
|
|
25
|
+
pollTimer: ReturnType<typeof setInterval> | null;
|
|
26
|
+
/** Newest message id already handled — the poll loop cuts at it. */
|
|
27
|
+
lastSeenMessageId: string | null;
|
|
28
|
+
myUserId: string | null;
|
|
29
|
+
/** Re-entrancy guard: a slow poll never overlaps the next tick. */
|
|
30
|
+
polling: boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function createTeamsRuntime(
|
|
34
|
+
config: TalonConfig,
|
|
35
|
+
gateway: Gateway,
|
|
36
|
+
): TeamsRuntime {
|
|
37
|
+
const raw = config as Record<string, unknown>;
|
|
38
|
+
return {
|
|
39
|
+
config,
|
|
40
|
+
gateway,
|
|
41
|
+
webhookUrl: raw.teamsWebhookUrl as string,
|
|
42
|
+
botDisplayName: (raw.teamsBotDisplayName as string) || "",
|
|
43
|
+
pollIntervalMs: (raw.teamsGraphPollMs as number) || 10_000,
|
|
44
|
+
configChatTopic: (raw.teamsChatTopic as string) || "",
|
|
45
|
+
graphClient: null,
|
|
46
|
+
pollTimer: null,
|
|
47
|
+
lastSeenMessageId: null,
|
|
48
|
+
myUserId: null,
|
|
49
|
+
polling: false,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
getAllCronJobs,
|
|
18
18
|
describeSchedule,
|
|
19
19
|
nextRunAt,
|
|
20
|
-
} from "../../storage/cron
|
|
20
|
+
} from "../../storage/cron.js";
|
|
21
21
|
import { getActiveCount } from "../../core/engine/dispatcher.js";
|
|
22
22
|
import { getPulseStatus } from "../../core/background/pulse.js";
|
|
23
23
|
import { getHealthStatus, getRecentErrors } from "../../util/watchdog.js";
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Only the plain Bot API (`getStickerSet`) is needed — no userbot.
|
|
14
14
|
* The read side (pack listing + the prompt index) lives in
|
|
15
|
-
* `storage/
|
|
15
|
+
* `storage/stickers.ts`, shared with prompt assembly.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
listSavedPacks,
|
|
24
24
|
type SavedPack,
|
|
25
25
|
type SavedSticker,
|
|
26
|
-
} from "../../storage/
|
|
26
|
+
} from "../../storage/stickers.js";
|
|
27
27
|
|
|
28
28
|
// ── Types ───────────────────────────────────────────────────────────────────
|
|
29
29
|
|
|
@@ -16,12 +16,9 @@
|
|
|
16
16
|
|
|
17
17
|
import { jidNormalizedUser, type WASocket } from "baileys";
|
|
18
18
|
import type { ActionResult } from "../../../core/types.js";
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
toUserJid,
|
|
23
|
-
tryAction,
|
|
24
|
-
} from "./shared.js";
|
|
19
|
+
import { tryAction } from "./try-action.js";
|
|
20
|
+
import { resolveMediaUpload } from "./media-upload.js";
|
|
21
|
+
import { readStatusText, toUserJid } from "./coerce.js";
|
|
25
22
|
import type { WhatsAppActionContext, WhatsAppActionHandlers } from "./types.js";
|
|
26
23
|
|
|
27
24
|
/** The account's own JID, device suffix stripped. */
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* retrieval (get_message_by_id, download_media) lives in history.ts.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import { tryAction } from "./try-action.js";
|
|
11
|
+
import { readStatusText, toUserJid } from "./coerce.js";
|
|
11
12
|
import type { WhatsAppActionHandlers } from "./types.js";
|
|
12
13
|
|
|
13
14
|
/** Render one participant the way the other frontends render members. */
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shape coercion for the member-facing WhatsApp actions: model-supplied user
|
|
3
|
+
* references → JIDs, and Baileys' several `fetchStatus` result shapes → text.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Coerce a user reference the model supplied — bare number, JID, or the
|
|
8
|
+
* numeric id from a member listing — into a WhatsApp user JID.
|
|
9
|
+
*/
|
|
10
|
+
export function toUserJid(value: unknown): string | null {
|
|
11
|
+
const raw = String(value ?? "").trim();
|
|
12
|
+
if (!raw) return null;
|
|
13
|
+
if (raw.includes("@")) return raw;
|
|
14
|
+
const digits = raw.replace(/[^0-9]/g, "");
|
|
15
|
+
return digits ? `${digits}@s.whatsapp.net` : null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Pull the about text out of a `fetchStatus` result. Baileys has
|
|
20
|
+
* returned both a bare object and a one-element array across versions,
|
|
21
|
+
* and the payload itself nests `status` either as a string or as
|
|
22
|
+
* `{ status }` — accept all four shapes rather than guess one.
|
|
23
|
+
*/
|
|
24
|
+
export function readStatusText(result: unknown): string | undefined {
|
|
25
|
+
const entry = (Array.isArray(result) ? result[0] : result) as
|
|
26
|
+
{ status?: string | { status?: string } } | undefined;
|
|
27
|
+
return typeof entry?.status === "string"
|
|
28
|
+
? entry.status
|
|
29
|
+
: entry?.status?.status;
|
|
30
|
+
}
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
} from "../../../storage/history.js";
|
|
20
20
|
import { saveInboundMedia } from "../media-store.js";
|
|
21
21
|
import { lookupMessage, resolveKey } from "../message-store.js";
|
|
22
|
-
import { tryAction } from "./
|
|
22
|
+
import { tryAction } from "./try-action.js";
|
|
23
23
|
import type { WhatsAppActionHandlers } from "./types.js";
|
|
24
24
|
|
|
25
25
|
function toMsgId(raw: unknown): number {
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media-source resolution for the WhatsApp send actions: workspace path or
|
|
3
|
+
* public URL → something Baileys can upload, plus the mimetype guess the
|
|
4
|
+
* document/audio paths need.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, statSync } from "node:fs";
|
|
8
|
+
import { basename, extname } from "node:path";
|
|
9
|
+
import { expandFsPath } from "../../../util/fs-path.js";
|
|
10
|
+
|
|
11
|
+
/** WhatsApp's own ceiling for a media upload. */
|
|
12
|
+
const MAX_MEDIA_BYTES = 64 * 1024 * 1024;
|
|
13
|
+
|
|
14
|
+
/** Baileys accepts a Buffer, a stream, or `{ url }` for local paths and HTTP. */
|
|
15
|
+
export type MediaUpload = { url: string };
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolve one media input to something Baileys can upload. Two sources:
|
|
19
|
+
* a public URL (WhatsApp's uploader fetches it) or a workspace file path
|
|
20
|
+
* (streamed from disk). `file_id` is a Telegram concept with no WhatsApp
|
|
21
|
+
* equivalent — say so instead of failing obscurely.
|
|
22
|
+
*/
|
|
23
|
+
export function resolveMediaUpload(
|
|
24
|
+
src: { file_path?: unknown; url?: unknown; file_id?: unknown },
|
|
25
|
+
label: string,
|
|
26
|
+
): { media: MediaUpload; fileName: string } | { error: string } {
|
|
27
|
+
if (src.url) {
|
|
28
|
+
const url = String(src.url);
|
|
29
|
+
return {
|
|
30
|
+
media: { url },
|
|
31
|
+
fileName: basename(new URL(url).pathname) || "file",
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
if (src.file_id) {
|
|
35
|
+
return {
|
|
36
|
+
error:
|
|
37
|
+
`${label}: WhatsApp has no file_id — re-send by url (public) or ` +
|
|
38
|
+
`file_path (workspace file)`,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (!src.file_path) {
|
|
42
|
+
return {
|
|
43
|
+
error: `${label}: provide file_path (workspace file) or url (public)`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const filePath = expandFsPath(String(src.file_path));
|
|
47
|
+
if (!existsSync(filePath)) {
|
|
48
|
+
return {
|
|
49
|
+
error: `File not found: ${filePath} — check the workspace path, or send by url instead`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
if (statSync(filePath).size > MAX_MEDIA_BYTES) {
|
|
53
|
+
return { error: `${label}: file exceeds WhatsApp's 64MB limit` };
|
|
54
|
+
}
|
|
55
|
+
return { media: { url: filePath }, fileName: basename(filePath) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Extension → mimetype for the document/audio paths that require one. */
|
|
59
|
+
const MIME_BY_EXT: Record<string, string> = {
|
|
60
|
+
".pdf": "application/pdf",
|
|
61
|
+
".txt": "text/plain",
|
|
62
|
+
".md": "text/markdown",
|
|
63
|
+
".json": "application/json",
|
|
64
|
+
".csv": "text/csv",
|
|
65
|
+
".zip": "application/zip",
|
|
66
|
+
".png": "image/png",
|
|
67
|
+
".jpg": "image/jpeg",
|
|
68
|
+
".jpeg": "image/jpeg",
|
|
69
|
+
".gif": "image/gif",
|
|
70
|
+
".webp": "image/webp",
|
|
71
|
+
".mp4": "video/mp4",
|
|
72
|
+
".mov": "video/quicktime",
|
|
73
|
+
".mp3": "audio/mpeg",
|
|
74
|
+
".m4a": "audio/mp4",
|
|
75
|
+
".ogg": "audio/ogg",
|
|
76
|
+
".opus": "audio/ogg; codecs=opus",
|
|
77
|
+
".wav": "audio/wav",
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export function guessMimetype(fileName: string, fallback: string): string {
|
|
81
|
+
return MIME_BY_EXT[extname(fileName).toLowerCase()] ?? fallback;
|
|
82
|
+
}
|
|
@@ -10,14 +10,9 @@
|
|
|
10
10
|
|
|
11
11
|
import type { AnyMessageContent } from "baileys";
|
|
12
12
|
import { toWhatsAppText } from "../formatting.js";
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
resolveQuoted,
|
|
17
|
-
sendContent,
|
|
18
|
-
sendText,
|
|
19
|
-
tryAction,
|
|
20
|
-
} from "./shared.js";
|
|
13
|
+
import { tryAction } from "./try-action.js";
|
|
14
|
+
import { guessMimetype, resolveMediaUpload } from "./media-upload.js";
|
|
15
|
+
import { resolveQuoted, sendContent, sendText } from "./send.js";
|
|
21
16
|
import type { WhatsAppActionHandlers } from "./types.js";
|
|
22
17
|
|
|
23
18
|
/** Optional caption, translated to WhatsApp's dialect. */
|
|
@@ -13,13 +13,8 @@ import { log } from "../../../util/log.js";
|
|
|
13
13
|
import { toWhatsAppText } from "../formatting.js";
|
|
14
14
|
import { lookupMessage, resolveKey } from "../message-store.js";
|
|
15
15
|
import { recordPin, listPins, forgetPin } from "../pins.js";
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
sendContent,
|
|
19
|
-
sendText,
|
|
20
|
-
tryAction,
|
|
21
|
-
boundedSend,
|
|
22
|
-
} from "./shared.js";
|
|
16
|
+
import { tryAction } from "./try-action.js";
|
|
17
|
+
import { resolveQuoted, sendContent, sendText, boundedSend } from "./send.js";
|
|
23
18
|
import type { WhatsAppActionHandlers } from "./types.js";
|
|
24
19
|
|
|
25
20
|
/** WhatsApp keeps a pin for 24h, 7d, or 30d — no indefinite option. */
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* rather than retrying something the platform cannot do.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import { tryAction } from "./try-action.js";
|
|
13
|
+
import { toUserJid } from "./coerce.js";
|
|
13
14
|
import type { WhatsAppActionHandlers } from "./types.js";
|
|
14
15
|
|
|
15
16
|
/** Ops that act on one member. */
|
|
@@ -1,111 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Outbound WhatsApp sends: quoted-reply resolution, the serialized
|
|
3
|
+
* time-bounded socket queue, and the content/text senders that remember
|
|
4
|
+
* what went out so later tool calls and history can address it.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { existsSync, statSync } from "node:fs";
|
|
8
|
-
import { basename, extname } from "node:path";
|
|
9
7
|
import type { AnyMessageContent, WAMessage, WASocket } from "baileys";
|
|
10
|
-
import {
|
|
11
|
-
import { log, logError } from "../../../util/log.js";
|
|
8
|
+
import { log } from "../../../util/log.js";
|
|
12
9
|
import type { ActionResult } from "../../../core/types.js";
|
|
13
10
|
import { toWhatsAppChunks } from "../formatting.js";
|
|
14
11
|
import { lookupMessage, rememberMessage } from "../message-store.js";
|
|
15
12
|
import { pushMessage, type HistoryMessage } from "../../../storage/history.js";
|
|
16
13
|
import type { WhatsAppChatInfo } from "../registry.js";
|
|
17
14
|
|
|
18
|
-
/** WhatsApp's own ceiling for a media upload. */
|
|
19
|
-
const MAX_MEDIA_BYTES = 64 * 1024 * 1024;
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Run an action, converting a throw into a structured failure. WhatsApp
|
|
23
|
-
* errors arrive as Boom objects whose message is the useful part; the
|
|
24
|
-
* model gets that text so it can adapt rather than retry blindly.
|
|
25
|
-
*/
|
|
26
|
-
export async function tryAction(
|
|
27
|
-
label: string,
|
|
28
|
-
fn: () => Promise<ActionResult>,
|
|
29
|
-
): Promise<ActionResult> {
|
|
30
|
-
try {
|
|
31
|
-
return await fn();
|
|
32
|
-
} catch (err) {
|
|
33
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
34
|
-
logError("whatsapp", `${label} failed: ${msg}`);
|
|
35
|
-
return { ok: false, error: `${label}: ${msg}` };
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** Baileys accepts a Buffer, a stream, or `{ url }` for local paths and HTTP. */
|
|
40
|
-
export type MediaUpload = { url: string };
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Resolve one media input to something Baileys can upload. Two sources:
|
|
44
|
-
* a public URL (WhatsApp's uploader fetches it) or a workspace file path
|
|
45
|
-
* (streamed from disk). `file_id` is a Telegram concept with no WhatsApp
|
|
46
|
-
* equivalent — say so instead of failing obscurely.
|
|
47
|
-
*/
|
|
48
|
-
export function resolveMediaUpload(
|
|
49
|
-
src: { file_path?: unknown; url?: unknown; file_id?: unknown },
|
|
50
|
-
label: string,
|
|
51
|
-
): { media: MediaUpload; fileName: string } | { error: string } {
|
|
52
|
-
if (src.url) {
|
|
53
|
-
const url = String(src.url);
|
|
54
|
-
return {
|
|
55
|
-
media: { url },
|
|
56
|
-
fileName: basename(new URL(url).pathname) || "file",
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
if (src.file_id) {
|
|
60
|
-
return {
|
|
61
|
-
error:
|
|
62
|
-
`${label}: WhatsApp has no file_id — re-send by url (public) or ` +
|
|
63
|
-
`file_path (workspace file)`,
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
if (!src.file_path) {
|
|
67
|
-
return {
|
|
68
|
-
error: `${label}: provide file_path (workspace file) or url (public)`,
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
const filePath = expandFsPath(String(src.file_path));
|
|
72
|
-
if (!existsSync(filePath)) {
|
|
73
|
-
return {
|
|
74
|
-
error: `File not found: ${filePath} — check the workspace path, or send by url instead`,
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
if (statSync(filePath).size > MAX_MEDIA_BYTES) {
|
|
78
|
-
return { error: `${label}: file exceeds WhatsApp's 64MB limit` };
|
|
79
|
-
}
|
|
80
|
-
return { media: { url: filePath }, fileName: basename(filePath) };
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Extension → mimetype for the document/audio paths that require one. */
|
|
84
|
-
const MIME_BY_EXT: Record<string, string> = {
|
|
85
|
-
".pdf": "application/pdf",
|
|
86
|
-
".txt": "text/plain",
|
|
87
|
-
".md": "text/markdown",
|
|
88
|
-
".json": "application/json",
|
|
89
|
-
".csv": "text/csv",
|
|
90
|
-
".zip": "application/zip",
|
|
91
|
-
".png": "image/png",
|
|
92
|
-
".jpg": "image/jpeg",
|
|
93
|
-
".jpeg": "image/jpeg",
|
|
94
|
-
".gif": "image/gif",
|
|
95
|
-
".webp": "image/webp",
|
|
96
|
-
".mp4": "video/mp4",
|
|
97
|
-
".mov": "video/quicktime",
|
|
98
|
-
".mp3": "audio/mpeg",
|
|
99
|
-
".m4a": "audio/mp4",
|
|
100
|
-
".ogg": "audio/ogg",
|
|
101
|
-
".opus": "audio/ogg; codecs=opus",
|
|
102
|
-
".wav": "audio/wav",
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
export function guessMimetype(fileName: string, fallback: string): string {
|
|
106
|
-
return MIME_BY_EXT[extname(fileName).toLowerCase()] ?? fallback;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
15
|
/**
|
|
110
16
|
* Resolve a `reply_to` message id into the quoted message Baileys wants.
|
|
111
17
|
* An unknown id quotes nothing rather than failing the send — the reply
|
|
@@ -292,29 +198,3 @@ export async function sendText(
|
|
|
292
198
|
);
|
|
293
199
|
return first ?? { ok: true };
|
|
294
200
|
}
|
|
295
|
-
|
|
296
|
-
/**
|
|
297
|
-
* Coerce a user reference the model supplied — bare number, JID, or the
|
|
298
|
-
* numeric id from a member listing — into a WhatsApp user JID.
|
|
299
|
-
*/
|
|
300
|
-
export function toUserJid(value: unknown): string | null {
|
|
301
|
-
const raw = String(value ?? "").trim();
|
|
302
|
-
if (!raw) return null;
|
|
303
|
-
if (raw.includes("@")) return raw;
|
|
304
|
-
const digits = raw.replace(/[^0-9]/g, "");
|
|
305
|
-
return digits ? `${digits}@s.whatsapp.net` : null;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
/**
|
|
309
|
-
* Pull the about text out of a `fetchStatus` result. Baileys has
|
|
310
|
-
* returned both a bare object and a one-element array across versions,
|
|
311
|
-
* and the payload itself nests `status` either as a string or as
|
|
312
|
-
* `{ status }` — accept all four shapes rather than guess one.
|
|
313
|
-
*/
|
|
314
|
-
export function readStatusText(result: unknown): string | undefined {
|
|
315
|
-
const entry = (Array.isArray(result) ? result[0] : result) as
|
|
316
|
-
{ status?: string | { status?: string } } | undefined;
|
|
317
|
-
return typeof entry?.status === "string"
|
|
318
|
-
? entry.status
|
|
319
|
-
: entry?.status?.status;
|
|
320
|
-
}
|