clawgram 2.1.0 → 2.2.1
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/README.md +39 -2
- package/dist/channel.js +148 -5
- package/dist/chat-info.js +97 -0
- package/dist/gramjs-client.js +60 -0
- package/dist/history.js +2 -0
- package/dist/media.js +100 -0
- package/dist/proxy-config.js +36 -1
- package/dist/reactions.js +65 -0
- package/dist/secret-refs.js +149 -0
- package/openclaw.plugin.json +128 -4
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -30,6 +30,8 @@ Clawgram is a personal-Telegram channel plugin for [OpenClaw](https://github.com
|
|
|
30
30
|
- **Forum topic routing** — correctly routes replies to the right forum topic thread
|
|
31
31
|
- **@Mention detection** — respond only when mentioned in groups (text, caption, and ID-based mentions)
|
|
32
32
|
- **Read receipts** — mark messages as read
|
|
33
|
+
- **Emoji reactions** — acknowledge a message with a reaction instead of a reply (`react` action)
|
|
34
|
+
- **Chat metadata** — title, type, member count, description, forum flag and pinned message (`chatInfo` action)
|
|
33
35
|
- **User allowlist** — control which user has access to send messages for direct
|
|
34
36
|
- **Chat allowlist** — control which chats the assistant can access
|
|
35
37
|
- **Multi-account** — run multiple Telegram accounts simultaneously
|
|
@@ -584,8 +586,8 @@ that means in practice, and what the code does about it:
|
|
|
584
586
|
|
|
585
587
|
| Concern | Where it lives | What the plugin does |
|
|
586
588
|
| --- | --- | --- |
|
|
587
|
-
| `apiHash`, `sessionString` | `openclaw.json
|
|
588
|
-
| Proxy password | `accounts.*.proxy.password
|
|
589
|
+
| `apiHash`, `sessionString` | `openclaw.json`, or a secret store | Written there by `--auth`. Never logged. Since 2.1.0 the session string is not printed after login either — only shown, behind an explicit warning, if you decline the automatic config write. Since 2.2.0 both accept a **SecretRef** instead of a literal, so the credential need not sit in the config file at all |
|
|
590
|
+
| Proxy password | `accounts.*.proxy.password`, or a secret store | Also accepts a SecretRef since 2.2.0. Marked `sensitive` in `uiHints`; diagnostics say `socks4`/`socks5` and nothing more. An invalid proxy fails the account rather than falling back to a direct connection, which would leak the host IP to Telegram |
|
|
589
591
|
| Message bodies | channel logs | **Not logged.** Outbound sends record recipient, ids and `textLength`. Until 2.1.0 the full outbound text was written to the channel log — if you ran 2.0.x, treat those journal entries as containing private correspondence |
|
|
590
592
|
| Read scope | `accounts.*.readChats` | History and membership reads are confined to the listed chats. Absent means no restriction; an empty array denies everything |
|
|
591
593
|
| Who may talk to it | `allowFrom`, `groups.*.groupPolicy` | Direct-message senders and group behaviour are allowlisted; `mention` limits group replies to explicit mentions |
|
|
@@ -593,6 +595,31 @@ that means in practice, and what the code does about it:
|
|
|
593
595
|
Two static tests (`test/no-secret-logging.test.ts`) fail the build if a message body or a credential
|
|
594
596
|
is ever added back to a log call, or if the auth flow prints the session string unprompted.
|
|
595
597
|
|
|
598
|
+
### Keeping credentials out of the config file
|
|
599
|
+
|
|
600
|
+
`apiHash`, `sessionString`, `proxy.username` and `proxy.password` accept a
|
|
601
|
+
[SecretRef](https://docs.openclaw.ai/gateway/secrets) in place of a literal value:
|
|
602
|
+
|
|
603
|
+
```json
|
|
604
|
+
{
|
|
605
|
+
"channels": {
|
|
606
|
+
"clawgram": {
|
|
607
|
+
"accounts": {
|
|
608
|
+
"default": {
|
|
609
|
+
"apiId": 12345678,
|
|
610
|
+
"apiHash": { "source": "file", "provider": "corp", "id": "/telegram/api-hash" },
|
|
611
|
+
"sessionString": { "source": "file", "provider": "corp", "id": "/telegram/session" }
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
References are resolved once per account at start-up. If one cannot be resolved the account fails to
|
|
620
|
+
start, naming the field but never the value — and the client refuses to be constructed while any
|
|
621
|
+
reference remains, so an unresolved secret can never travel to Telegram as a credential.
|
|
622
|
+
|
|
596
623
|
Found a security issue? Open an issue at
|
|
597
624
|
[github.com/d3pre5s/clawgram/issues](https://github.com/d3pre5s/clawgram/issues) — or, if it is
|
|
598
625
|
sensitive, contact the maintainer directly instead of filing publicly.
|
|
@@ -617,6 +644,16 @@ or
|
|
|
617
644
|
npm run clawgram-cli:auth
|
|
618
645
|
```
|
|
619
646
|
|
|
647
|
+
## Releases
|
|
648
|
+
|
|
649
|
+
Every push and pull request is built and tested on Node 22 and 24. Releases are cut by pushing a
|
|
650
|
+
`v<version>` tag: CI then builds from a clean checkout and publishes to npm with
|
|
651
|
+
[provenance](https://docs.npmjs.com/generating-provenance-statements), so each published tarball is
|
|
652
|
+
verifiably tied to the commit it was built from. The tag and `package.json` must agree or the job
|
|
653
|
+
refuses to publish.
|
|
654
|
+
|
|
655
|
+
Changes per version: [CHANGELOG.md](CHANGELOG.md).
|
|
656
|
+
|
|
620
657
|
## License
|
|
621
658
|
|
|
622
659
|
MIT
|
package/dist/channel.js
CHANGED
|
@@ -16,6 +16,10 @@ const gramjs_client_1 = require("./gramjs-client");
|
|
|
16
16
|
const normalize_1 = require("./normalize");
|
|
17
17
|
const history_1 = require("./history");
|
|
18
18
|
const joins_1 = require("./joins");
|
|
19
|
+
const reactions_1 = require("./reactions");
|
|
20
|
+
const chat_info_1 = require("./chat-info");
|
|
21
|
+
const secret_refs_1 = require("./secret-refs");
|
|
22
|
+
const secret_ref_runtime_1 = require("openclaw/plugin-sdk/secret-ref-runtime");
|
|
19
23
|
const group_reply_address_1 = require("./group-reply-address");
|
|
20
24
|
const group_visible_reply_guard_1 = require("./group-visible-reply-guard");
|
|
21
25
|
const helpers_1 = require("./helpers");
|
|
@@ -86,11 +90,15 @@ const createChannelPlugin = (runtimes) => {
|
|
|
86
90
|
"When replying in the current Telegram chat, omit `to`/`target` and clawgram will send to the current conversation automatically.",
|
|
87
91
|
"Explicit targets may be @username, numeric Telegram user id, phone/contact resolvable by Telegram, group chat ids, or clawgram:<target>.",
|
|
88
92
|
"For Telegram forum topics, send to the group chat id and pass the topic id separately as `threadId`.",
|
|
93
|
+
"Use the `react` action to acknowledge a message with an emoji instead of sending a reply; pass an empty `emoji` (or `remove: true`) to take the reaction back.",
|
|
94
|
+
"Use the `chatInfo` action to learn what a chat is — title, type, member count, description, pinned message — instead of guessing from its id.",
|
|
89
95
|
],
|
|
90
96
|
messageToolCapabilities: () => [
|
|
91
97
|
"clawgram can reply in the current Telegram conversation when no explicit target is provided.",
|
|
92
98
|
"clawgram can send text messages to direct chats and groups from the connected personal account.",
|
|
93
99
|
"clawgram supports Telegram forum topics via the `threadId` parameter on group sends.",
|
|
100
|
+
"clawgram can add and clear emoji reactions on messages. A plain Telegram account holds one reaction per message, so a new emoji replaces the previous one.",
|
|
101
|
+
"clawgram can describe a chat via `chatInfo`: title, type (direct/group/supergroup/channel), member count, description, whether it is a forum, and the pinned message id.",
|
|
94
102
|
],
|
|
95
103
|
},
|
|
96
104
|
config: {
|
|
@@ -105,8 +113,8 @@ const createChannelPlugin = (runtimes) => {
|
|
|
105
113
|
const account = cfg?.channels?.["clawgram"]?.accounts?.[accountId];
|
|
106
114
|
return {
|
|
107
115
|
apiId: Number(account?.apiId),
|
|
108
|
-
apiHash:
|
|
109
|
-
sessionString:
|
|
116
|
+
apiHash: (0, secret_refs_1.readSecretInput)(account?.apiHash),
|
|
117
|
+
sessionString: (0, secret_refs_1.readSecretInput)(account?.sessionString),
|
|
110
118
|
allowFrom: (0, helpers_1.resolveAllowFrom)(account?.allowFrom),
|
|
111
119
|
groups: (0, helpers_1.resolveGroups)(account?.groups),
|
|
112
120
|
readChats: readAccountReadChats(account),
|
|
@@ -127,7 +135,32 @@ const createChannelPlugin = (runtimes) => {
|
|
|
127
135
|
await runtimes.get(accountId)?.stop().catch(() => undefined);
|
|
128
136
|
runtimes.delete(accountId);
|
|
129
137
|
}
|
|
130
|
-
|
|
138
|
+
// Credentials may be SecretRefs rather than literals. Resolve them here,
|
|
139
|
+
// once per account start, and hand the client only resolved values.
|
|
140
|
+
// Failing loudly beats starting with a blank credential and getting an
|
|
141
|
+
// authentication error that says nothing about the real cause.
|
|
142
|
+
const secretRefs = (0, secret_refs_1.collectAccountSecretRefs)(account);
|
|
143
|
+
let resolvedAccount = account;
|
|
144
|
+
if (secretRefs.length > 0) {
|
|
145
|
+
// `source` is whatever the config says; OpenClaw validates it and
|
|
146
|
+
// reports an unknown source better than a local check would.
|
|
147
|
+
const values = await (0, secret_ref_runtime_1.resolveSecretRefValues)(secretRefs, {
|
|
148
|
+
config: cfg,
|
|
149
|
+
env: process.env,
|
|
150
|
+
});
|
|
151
|
+
const applied = (0, secret_refs_1.applyAccountSecrets)(account, values);
|
|
152
|
+
if (applied.missing.length > 0) {
|
|
153
|
+
// Field names only. The value is what we are protecting, and the
|
|
154
|
+
// reference itself names a location in the secret store.
|
|
155
|
+
throw new Error(`clawgram: could not resolve secret references for ${applied.missing.join(", ")}`);
|
|
156
|
+
}
|
|
157
|
+
log?.info?.("clawgram resolved secret references", {
|
|
158
|
+
accountId,
|
|
159
|
+
fields: secretRefs.length,
|
|
160
|
+
});
|
|
161
|
+
resolvedAccount = applied.account;
|
|
162
|
+
}
|
|
163
|
+
const gram = new gramjs_client_1.GramJsClientManager(resolvedAccount);
|
|
131
164
|
await gram.start();
|
|
132
165
|
runtimes.set(accountId, gram);
|
|
133
166
|
const pairing = (0, channel_pairing_1.createChannelPairingController)({
|
|
@@ -918,7 +951,7 @@ const createChannelPlugin = (runtimes) => {
|
|
|
918
951
|
return null;
|
|
919
952
|
}
|
|
920
953
|
return {
|
|
921
|
-
actions: ["send", "read", "participants", "joins"],
|
|
954
|
+
actions: ["send", "read", "participants", "joins", "react", "chatInfo"],
|
|
922
955
|
capabilities: [],
|
|
923
956
|
};
|
|
924
957
|
},
|
|
@@ -1030,6 +1063,82 @@ const createChannelPlugin = (runtimes) => {
|
|
|
1030
1063
|
joins: selected,
|
|
1031
1064
|
});
|
|
1032
1065
|
}
|
|
1066
|
+
// Describing a chat is a read, so the same `readChats` scope that gates
|
|
1067
|
+
// history gates it too — this must not become a way to learn the title
|
|
1068
|
+
// and size of a chat the account was never allowed to read.
|
|
1069
|
+
if (action === "chatInfo" || action === "getChatInfo"
|
|
1070
|
+
|| action === "chatMetadata" || action === "getChatMetadata") {
|
|
1071
|
+
const chatInfoParams = (0, chat_info_1.parseChatInfoParams)(params, toolContext);
|
|
1072
|
+
const chatInfoAccountId = resolveRuntimeAccountId(cfg, accountId);
|
|
1073
|
+
if (!chatInfoAccountId) {
|
|
1074
|
+
throw new Error("clawgram: no configured account found");
|
|
1075
|
+
}
|
|
1076
|
+
if (!(0, history_1.isChatReadable)(chatInfoParams.target, resolveAccountReadChats(cfg, chatInfoAccountId))) {
|
|
1077
|
+
actionLog.warn("clawgram chatInfo refused: chat outside read scope", {
|
|
1078
|
+
accountId: chatInfoAccountId,
|
|
1079
|
+
target: chatInfoParams.target,
|
|
1080
|
+
});
|
|
1081
|
+
throw new Error(`clawgram: not-allowed-chat ${chatInfoParams.target}`);
|
|
1082
|
+
}
|
|
1083
|
+
const chatInfoGram = runtimes.get(chatInfoAccountId);
|
|
1084
|
+
if (!chatInfoGram) {
|
|
1085
|
+
throw new Error(`clawgram: runtime not found for account ${chatInfoAccountId}`);
|
|
1086
|
+
}
|
|
1087
|
+
const { entity, full } = await chatInfoGram.getChatInfo(chatInfoParams.target);
|
|
1088
|
+
const info = (0, chat_info_1.describeChat)(entity, full);
|
|
1089
|
+
// Type and size only. The title of a private chat is as personal as
|
|
1090
|
+
// its contents and has no business in a debugging log.
|
|
1091
|
+
actionLog.info("clawgram handleAction chatInfo completed", {
|
|
1092
|
+
accountId: chatInfoAccountId,
|
|
1093
|
+
type: info.type,
|
|
1094
|
+
memberCount: info.memberCount ?? null,
|
|
1095
|
+
isForum: info.isForum ?? null,
|
|
1096
|
+
});
|
|
1097
|
+
return (0, core_1.jsonResult)({
|
|
1098
|
+
ok: true,
|
|
1099
|
+
accountId: chatInfoAccountId,
|
|
1100
|
+
chat: { ...info, chatId: info.chatId ?? chatInfoParams.target },
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
// A reaction is an outbound act on someone else's message, so it is
|
|
1104
|
+
// gated like sending rather than like reading — and it respects
|
|
1105
|
+
// `dryRun`, which reading does not need to.
|
|
1106
|
+
if (action === "react") {
|
|
1107
|
+
const reactionParams = (0, reactions_1.parseReactionParams)(params, toolContext);
|
|
1108
|
+
const reactionAccountId = resolveRuntimeAccountId(cfg, accountId);
|
|
1109
|
+
if (!reactionAccountId) {
|
|
1110
|
+
throw new Error("clawgram: no configured account found");
|
|
1111
|
+
}
|
|
1112
|
+
actionLog.info("clawgram handleAction react", {
|
|
1113
|
+
accountId: reactionAccountId,
|
|
1114
|
+
dryRun: dryRun === true,
|
|
1115
|
+
target: reactionParams.target,
|
|
1116
|
+
messageId: reactionParams.messageId,
|
|
1117
|
+
remove: reactionParams.remove,
|
|
1118
|
+
});
|
|
1119
|
+
if (dryRun === true) {
|
|
1120
|
+
return (0, core_1.jsonResult)({
|
|
1121
|
+
ok: true,
|
|
1122
|
+
dryRun: true,
|
|
1123
|
+
accountId: reactionAccountId,
|
|
1124
|
+
chatId: reactionParams.target,
|
|
1125
|
+
messageId: reactionParams.messageId,
|
|
1126
|
+
removed: reactionParams.remove,
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
const reactionGram = runtimes.get(reactionAccountId);
|
|
1130
|
+
if (!reactionGram) {
|
|
1131
|
+
throw new Error(`clawgram: runtime not found for account ${reactionAccountId}`);
|
|
1132
|
+
}
|
|
1133
|
+
await reactionGram.sendReaction(reactionParams);
|
|
1134
|
+
return (0, core_1.jsonResult)({
|
|
1135
|
+
ok: true,
|
|
1136
|
+
accountId: reactionAccountId,
|
|
1137
|
+
chatId: reactionParams.target,
|
|
1138
|
+
messageId: reactionParams.messageId,
|
|
1139
|
+
removed: reactionParams.remove,
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1033
1142
|
if (action !== "send") {
|
|
1034
1143
|
throw new Error(`clawgram: unsupported message action ${action}`);
|
|
1035
1144
|
}
|
|
@@ -1086,7 +1195,30 @@ const createChannelPlugin = (runtimes) => {
|
|
|
1086
1195
|
chatId: to,
|
|
1087
1196
|
replyToId,
|
|
1088
1197
|
});
|
|
1089
|
-
const
|
|
1198
|
+
const requestedText = (0, helpers_1.readMessageText)(params).replaceAll("\\n", "\n");
|
|
1199
|
+
// `NO_REPLY` is OpenClaw's "say nothing" sentinel. The inbound pipeline
|
|
1200
|
+
// and core both strip it, but an explicit `message.action` call is
|
|
1201
|
+
// neither path — and the SDK itself prompts agents to send a message
|
|
1202
|
+
// and *then* answer NO_REPLY, so the two are one slip apart. Posting
|
|
1203
|
+
// the token into a work chat looks like the assistant malfunctioning.
|
|
1204
|
+
//
|
|
1205
|
+
// Checked before the reply-address prefix on purpose: prefixing first
|
|
1206
|
+
// leaves "Name: " behind, which is not empty, and the token goes out.
|
|
1207
|
+
// That is precisely how it once reached the inbound path.
|
|
1208
|
+
if (requestedText.trim() && (0, helpers_1.isSilentReplyText)(requestedText)) {
|
|
1209
|
+
actionLog.info("clawgram suppressing silent send", {
|
|
1210
|
+
accountId: resolvedAccountId,
|
|
1211
|
+
to,
|
|
1212
|
+
});
|
|
1213
|
+
return (0, core_1.jsonResult)({
|
|
1214
|
+
ok: true,
|
|
1215
|
+
skipped: "silent",
|
|
1216
|
+
sent: false,
|
|
1217
|
+
to,
|
|
1218
|
+
accountId: resolvedAccountId,
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
const text = (0, helpers_1.prefixReplyTextToAddress)(requestedText, groupReplyAddress);
|
|
1090
1222
|
if (!text) {
|
|
1091
1223
|
throw new Error("clawgram: message text is required");
|
|
1092
1224
|
}
|
|
@@ -1161,6 +1293,17 @@ const createChannelPlugin = (runtimes) => {
|
|
|
1161
1293
|
threadId: ctx.threadId ?? null,
|
|
1162
1294
|
textLength: ctx.text.length,
|
|
1163
1295
|
});
|
|
1296
|
+
// Core normalizes reply payloads and drops the silent token before a
|
|
1297
|
+
// channel is called, so this should never see one. "Should never" is
|
|
1298
|
+
// what the inbound path was assumed to be too, right until it posted a
|
|
1299
|
+
// token — and the check costs a string comparison.
|
|
1300
|
+
if (ctx.text.trim() && (0, helpers_1.isSilentReplyText)(ctx.text)) {
|
|
1301
|
+
actionLog.info("clawgram suppressing silent outbound send", {
|
|
1302
|
+
accountId: ctx.accountId,
|
|
1303
|
+
rawTo: ctx.to,
|
|
1304
|
+
});
|
|
1305
|
+
return { skipped: "silent" };
|
|
1306
|
+
}
|
|
1164
1307
|
const gram = runtimes.get(ctx.accountId);
|
|
1165
1308
|
if (!gram) {
|
|
1166
1309
|
throw new Error(`clawgram: runtime not found for account ${ctx.accountId}`);
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Chat metadata — what a chat *is*, as opposed to what was said in it.
|
|
4
|
+
*
|
|
5
|
+
* Without this the assistant can read a chat and list its members but cannot
|
|
6
|
+
* say which chat it is standing in: the title, whether it is a work supergroup
|
|
7
|
+
* or a one-to-one conversation, whether replies must be addressed to a forum
|
|
8
|
+
* topic. That had to come from a hand-maintained allowlist, which goes stale
|
|
9
|
+
* the moment a chat is renamed.
|
|
10
|
+
*
|
|
11
|
+
* Telegram splits this across two objects — the entity carries the title and
|
|
12
|
+
* the type flags, the full object carries the description, the member count
|
|
13
|
+
* and the pinned message — so both are taken and merged here. Parsing is pure
|
|
14
|
+
* so it can be tested without a Telegram client; the transport lives in
|
|
15
|
+
* `GramJsClientManager.getChatInfo`.
|
|
16
|
+
*/
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.parseChatInfoParams = parseChatInfoParams;
|
|
19
|
+
exports.describeChat = describeChat;
|
|
20
|
+
function parseChatInfoParams(params, toolContext) {
|
|
21
|
+
const rawTarget = params.chatId ?? params.target ?? params.to ?? params.chat ?? toolContext?.currentChannelId;
|
|
22
|
+
const target = typeof rawTarget === "string" ? rawTarget.trim() : "";
|
|
23
|
+
if (!target) {
|
|
24
|
+
throw new Error("clawgram: chatInfo requires a chatId");
|
|
25
|
+
}
|
|
26
|
+
return { target };
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* GramJS carries ids and counts as `big-integer` objects as often as native
|
|
30
|
+
* numbers — the shape that once made `senderId` come back silently undefined.
|
|
31
|
+
*/
|
|
32
|
+
function readNumber(value) {
|
|
33
|
+
if (typeof value === "number") {
|
|
34
|
+
return Number.isFinite(value) ? value : undefined;
|
|
35
|
+
}
|
|
36
|
+
if (value === undefined || value === null) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
const parsed = Number(String(value));
|
|
40
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
41
|
+
}
|
|
42
|
+
function readString(value) {
|
|
43
|
+
if (typeof value !== "string") {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
const trimmed = value.trim();
|
|
47
|
+
return trimmed === "" ? undefined : trimmed;
|
|
48
|
+
}
|
|
49
|
+
function readId(value) {
|
|
50
|
+
if (value === undefined || value === null) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
const asString = String(value).trim();
|
|
54
|
+
return asString === "" || asString === "[object Object]" ? undefined : asString;
|
|
55
|
+
}
|
|
56
|
+
function resolveType(entity) {
|
|
57
|
+
switch (entity?.className) {
|
|
58
|
+
case "User":
|
|
59
|
+
return "direct";
|
|
60
|
+
case "Chat":
|
|
61
|
+
return "group";
|
|
62
|
+
case "Channel":
|
|
63
|
+
return entity.broadcast === true ? "channel" : "supergroup";
|
|
64
|
+
default:
|
|
65
|
+
return "unknown";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** A user has no title, so the displayed name is assembled from what exists. */
|
|
69
|
+
function resolveUserTitle(entity) {
|
|
70
|
+
const parts = [readString(entity?.firstName), readString(entity?.lastName)].filter(Boolean);
|
|
71
|
+
return parts.length > 0 ? parts.join(" ") : undefined;
|
|
72
|
+
}
|
|
73
|
+
function describeChat(entity, full) {
|
|
74
|
+
const raw = entity;
|
|
75
|
+
const fullChat = full;
|
|
76
|
+
const type = resolveType(raw);
|
|
77
|
+
const info = {
|
|
78
|
+
chatId: readId(raw?.id),
|
|
79
|
+
type,
|
|
80
|
+
title: type === "direct" ? resolveUserTitle(raw) : readString(raw?.title),
|
|
81
|
+
username: readString(raw?.username),
|
|
82
|
+
about: readString(fullChat?.about),
|
|
83
|
+
pinnedMessageId: readId(fullChat?.pinnedMsgId),
|
|
84
|
+
};
|
|
85
|
+
if (type === "direct") {
|
|
86
|
+
// Member count is meaningless for a two-person conversation, and reporting
|
|
87
|
+
// "1" or "2" would invite a reader to treat it as a group of that size.
|
|
88
|
+
return { ...info, isBot: raw?.bot === true };
|
|
89
|
+
}
|
|
90
|
+
if (type === "supergroup" || type === "channel") {
|
|
91
|
+
info.isForum = raw?.forum === true;
|
|
92
|
+
}
|
|
93
|
+
// The full object is fetched now; the entity may come from a cache that
|
|
94
|
+
// predates the last few joins, so prefer the fresher number.
|
|
95
|
+
info.memberCount = readNumber(fullChat?.participantsCount) ?? readNumber(raw?.participantsCount);
|
|
96
|
+
return info;
|
|
97
|
+
}
|
package/dist/gramjs-client.js
CHANGED
|
@@ -4,6 +4,7 @@ exports.GramJsClientManager = void 0;
|
|
|
4
4
|
const telegram_1 = require("telegram");
|
|
5
5
|
const sessions_1 = require("telegram/sessions");
|
|
6
6
|
const proxy_config_1 = require("./proxy-config");
|
|
7
|
+
const secret_refs_1 = require("./secret-refs");
|
|
7
8
|
const history_1 = require("./history");
|
|
8
9
|
function toStringId(value) {
|
|
9
10
|
if (value === null || value === undefined)
|
|
@@ -168,6 +169,15 @@ class GramJsClientManager {
|
|
|
168
169
|
started = false;
|
|
169
170
|
constructor(config) {
|
|
170
171
|
this.config = config;
|
|
172
|
+
// Credentials may be written as SecretRefs; account start-up resolves them
|
|
173
|
+
// before constructing this. Refusing here rather than trusting the caller
|
|
174
|
+
// keeps an unresolved reference from being sent to Telegram as the literal
|
|
175
|
+
// string "[object Object]" — which comes back as a complaint about the
|
|
176
|
+
// credential, not about the secret that failed to resolve.
|
|
177
|
+
if ((0, secret_refs_1.hasUnresolvedSecretRef)(config)) {
|
|
178
|
+
throw new Error("clawgram: account credentials still contain unresolved secret references; "
|
|
179
|
+
+ "they must be resolved before the client is created");
|
|
180
|
+
}
|
|
171
181
|
const clientOptions = (0, proxy_config_1.buildTelegramClientOptions)(config.proxy);
|
|
172
182
|
this.proxy = clientOptions.proxy;
|
|
173
183
|
this.client = new telegram_1.TelegramClient(new sessions_1.StringSession(config.sessionString), config.apiId, config.apiHash, clientOptions);
|
|
@@ -288,6 +298,56 @@ class GramJsClientManager {
|
|
|
288
298
|
...replyParams,
|
|
289
299
|
});
|
|
290
300
|
}
|
|
301
|
+
/**
|
|
302
|
+
* Reads what a chat is: title, type, member count, description, pinned
|
|
303
|
+
* message.
|
|
304
|
+
*
|
|
305
|
+
* Telegram has no single "describe this chat" call — the full object comes
|
|
306
|
+
* from a different method per chat type, and none of them accepts the other's
|
|
307
|
+
* peer. The entity is resolved first precisely to find out which one to ask.
|
|
308
|
+
* A failing full request is not fatal: the entity alone already carries the
|
|
309
|
+
* title and the type, and a partial answer beats an error when the caller
|
|
310
|
+
* only wanted to know where it is.
|
|
311
|
+
*/
|
|
312
|
+
async getChatInfo(target) {
|
|
313
|
+
const resolved = await this.resolvePeer(target);
|
|
314
|
+
const entity = await this.client.getEntity(resolved.peer);
|
|
315
|
+
const full = await (async () => {
|
|
316
|
+
switch (entity?.className) {
|
|
317
|
+
case "Channel":
|
|
318
|
+
return (await this.client.invoke(new telegram_1.Api.channels.GetFullChannel({
|
|
319
|
+
channel: entity,
|
|
320
|
+
}))).fullChat;
|
|
321
|
+
case "Chat":
|
|
322
|
+
return (await this.client.invoke(new telegram_1.Api.messages.GetFullChat({
|
|
323
|
+
chatId: entity.id,
|
|
324
|
+
}))).fullChat;
|
|
325
|
+
case "User":
|
|
326
|
+
return (await this.client.invoke(new telegram_1.Api.users.GetFullUser({
|
|
327
|
+
id: entity,
|
|
328
|
+
}))).fullUser;
|
|
329
|
+
default:
|
|
330
|
+
return undefined;
|
|
331
|
+
}
|
|
332
|
+
})().catch(() => undefined);
|
|
333
|
+
return { entity, full };
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Adds or clears this account's reaction on a message.
|
|
337
|
+
*
|
|
338
|
+
* Telegram models "no reaction" as an empty reaction list rather than a
|
|
339
|
+
* separate call, so removal is the same request with nothing in it. A plain
|
|
340
|
+
* account may hold only one reaction per message, which is why removing a
|
|
341
|
+
* specific emoji and clearing collapse to the same thing here.
|
|
342
|
+
*/
|
|
343
|
+
async sendReaction(args) {
|
|
344
|
+
const resolved = await this.resolvePeer(args.target);
|
|
345
|
+
await this.client.invoke(new telegram_1.Api.messages.SendReaction({
|
|
346
|
+
peer: resolved.peer,
|
|
347
|
+
msgId: args.messageId,
|
|
348
|
+
reaction: args.remove ? [] : [new telegram_1.Api.ReactionEmoji({ emoticon: args.emoji })],
|
|
349
|
+
}));
|
|
350
|
+
}
|
|
291
351
|
/**
|
|
292
352
|
* Reads a window of chat history.
|
|
293
353
|
*
|
package/dist/history.js
CHANGED
|
@@ -24,6 +24,7 @@ exports.normalizeHistoryMessage = normalizeHistoryMessage;
|
|
|
24
24
|
exports.collectHistoryWindow = collectHistoryWindow;
|
|
25
25
|
exports.HISTORY_DEFAULT_LIMIT = 100;
|
|
26
26
|
exports.HISTORY_MAX_LIMIT = 500;
|
|
27
|
+
const media_1 = require("./media");
|
|
27
28
|
/**
|
|
28
29
|
* Matches `normalize.ts` and `gramjs-client.ts` deliberately.
|
|
29
30
|
*
|
|
@@ -258,6 +259,7 @@ function normalizeHistoryMessage(msg, fallbackChatId) {
|
|
|
258
259
|
sentAt: timestamp === undefined ? undefined : new Date(timestamp * 1000).toISOString(),
|
|
259
260
|
replyToMessageId: toStringId(msg?.replyTo?.replyToMsgId) ?? toStringId(msg?.replyToMsgId),
|
|
260
261
|
messageThreadId: toStringId(msg?.replyTo?.replyToTopId) ?? toStringId(msg?.replyToTopId),
|
|
262
|
+
media: (0, media_1.describeMedia)(msg?.media),
|
|
261
263
|
isOutgoing: msg?.out === true,
|
|
262
264
|
};
|
|
263
265
|
}
|
package/dist/media.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Attachment metadata for history reads.
|
|
4
|
+
*
|
|
5
|
+
* A message whose whole content is a screenshot used to arrive as an empty
|
|
6
|
+
* `text` — indistinguishable from a message that said nothing. That is a real
|
|
7
|
+
* loss for a reader summarizing a work chat, where the screenshot of the error
|
|
8
|
+
* *is* the report.
|
|
9
|
+
*
|
|
10
|
+
* Only metadata is produced. Nothing is downloaded: knowing that "spec.pdf,
|
|
11
|
+
* 240 KB" was posted is what a summary needs, and fetching the bytes of every
|
|
12
|
+
* attachment in a window would be a different feature with different costs.
|
|
13
|
+
*/
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.describeMedia = describeMedia;
|
|
16
|
+
/**
|
|
17
|
+
* GramJS carries numbers as `big-integer` objects as often as native numbers —
|
|
18
|
+
* the same shape that once made `senderId` silently undefined. Anything that
|
|
19
|
+
* stringifies to digits is accepted.
|
|
20
|
+
*/
|
|
21
|
+
function readNumber(value) {
|
|
22
|
+
if (typeof value === "number") {
|
|
23
|
+
return Number.isFinite(value) ? value : undefined;
|
|
24
|
+
}
|
|
25
|
+
if (value === undefined || value === null) {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
const parsed = Number(String(value));
|
|
29
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
30
|
+
}
|
|
31
|
+
function readAttributes(document) {
|
|
32
|
+
const attributes = document?.attributes;
|
|
33
|
+
return Array.isArray(attributes) ? attributes : [];
|
|
34
|
+
}
|
|
35
|
+
function findAttribute(document, className) {
|
|
36
|
+
return readAttributes(document).find((attribute) => attribute?.className === className);
|
|
37
|
+
}
|
|
38
|
+
const SIMPLE_KINDS = {
|
|
39
|
+
MessageMediaPhoto: "photo",
|
|
40
|
+
MessageMediaPoll: "poll",
|
|
41
|
+
MessageMediaGeo: "geo",
|
|
42
|
+
MessageMediaGeoLive: "geo",
|
|
43
|
+
MessageMediaContact: "contact",
|
|
44
|
+
MessageMediaWebPage: "webpage",
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Documents are the ambiguous case: a voice note, a video, a sticker and a
|
|
48
|
+
* spreadsheet are all `MessageMediaDocument`, separated only by attributes.
|
|
49
|
+
*/
|
|
50
|
+
function describeDocument(document) {
|
|
51
|
+
const mimeType = typeof document?.mimeType === "string" ? document.mimeType : undefined;
|
|
52
|
+
const size = readNumber(document?.size);
|
|
53
|
+
const fileName = findAttribute(document, "DocumentAttributeFilename")?.fileName;
|
|
54
|
+
const base = {
|
|
55
|
+
kind: "document",
|
|
56
|
+
fileName: typeof fileName === "string" ? fileName : undefined,
|
|
57
|
+
mimeType,
|
|
58
|
+
size,
|
|
59
|
+
};
|
|
60
|
+
const sticker = findAttribute(document, "DocumentAttributeSticker");
|
|
61
|
+
if (sticker) {
|
|
62
|
+
return { ...base, kind: "sticker", emoji: typeof sticker.alt === "string" ? sticker.alt : undefined };
|
|
63
|
+
}
|
|
64
|
+
const audio = findAttribute(document, "DocumentAttributeAudio");
|
|
65
|
+
if (audio) {
|
|
66
|
+
const duration = readNumber(audio.duration);
|
|
67
|
+
return {
|
|
68
|
+
...base,
|
|
69
|
+
kind: audio.voice === true ? "voice" : "audio",
|
|
70
|
+
durationSeconds: duration === undefined ? undefined : Math.round(duration),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const video = findAttribute(document, "DocumentAttributeVideo");
|
|
74
|
+
if (video) {
|
|
75
|
+
const duration = readNumber(video.duration);
|
|
76
|
+
return {
|
|
77
|
+
...base,
|
|
78
|
+
kind: "video",
|
|
79
|
+
durationSeconds: duration === undefined ? undefined : Math.round(duration),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return base;
|
|
83
|
+
}
|
|
84
|
+
function describeMedia(media) {
|
|
85
|
+
const raw = media;
|
|
86
|
+
const className = raw?.className;
|
|
87
|
+
if (!className || typeof className !== "string" || className === "MessageMediaEmpty") {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
const simple = SIMPLE_KINDS[className];
|
|
91
|
+
if (simple) {
|
|
92
|
+
return { kind: simple };
|
|
93
|
+
}
|
|
94
|
+
if (className === "MessageMediaDocument") {
|
|
95
|
+
return describeDocument(raw.document);
|
|
96
|
+
}
|
|
97
|
+
// Telegram keeps adding media types. An unmodelled one still has to show up
|
|
98
|
+
// as "something was attached" — a blank message is the failure being fixed.
|
|
99
|
+
return { kind: "other", telegramType: className };
|
|
100
|
+
}
|
package/dist/proxy-config.js
CHANGED
|
@@ -37,12 +37,29 @@ function resolveSocksType(value) {
|
|
|
37
37
|
}
|
|
38
38
|
return socksType;
|
|
39
39
|
}
|
|
40
|
+
/** Shape check only; the real resolution happens in `secret-refs`. */
|
|
41
|
+
function asProxySecretRef(value) {
|
|
42
|
+
if (!value || typeof value !== "object")
|
|
43
|
+
return undefined;
|
|
44
|
+
const candidate = value;
|
|
45
|
+
return typeof candidate.source === "string"
|
|
46
|
+
&& typeof candidate.provider === "string"
|
|
47
|
+
&& typeof candidate.id === "string"
|
|
48
|
+
? { source: candidate.source, provider: candidate.provider, id: candidate.id }
|
|
49
|
+
: undefined;
|
|
50
|
+
}
|
|
40
51
|
function resolveProxyCredential(value, field) {
|
|
41
52
|
if (value === undefined || value === null) {
|
|
42
53
|
return undefined;
|
|
43
54
|
}
|
|
55
|
+
// A SecretRef is resolved later, at account start-up. Rejecting it here
|
|
56
|
+
// would make the config unloadable before the resolver ever runs.
|
|
57
|
+
const ref = asProxySecretRef(value);
|
|
58
|
+
if (ref) {
|
|
59
|
+
return ref;
|
|
60
|
+
}
|
|
44
61
|
if (typeof value !== "string") {
|
|
45
|
-
throw new Error(`clawgram: proxy.${field} must be a string.`);
|
|
62
|
+
throw new Error(`clawgram: proxy.${field} must be a string or a secret reference.`);
|
|
46
63
|
}
|
|
47
64
|
return value.trim() ? value : undefined;
|
|
48
65
|
}
|
|
@@ -95,11 +112,29 @@ function buildTelegramClientOptions(proxy) {
|
|
|
95
112
|
connectionRetries: CONNECTION_RETRIES,
|
|
96
113
|
};
|
|
97
114
|
}
|
|
115
|
+
// Secret references are substituted before the client is built, and the
|
|
116
|
+
// account start-up refuses to continue while any remain — so by here the
|
|
117
|
+
// credentials are strings. The cast states that, and `assertProxyResolved`
|
|
118
|
+
// enforces it rather than trusting it.
|
|
119
|
+
assertProxyResolved(resolved);
|
|
98
120
|
return {
|
|
99
121
|
connectionRetries: CONNECTION_RETRIES,
|
|
100
122
|
proxy: resolved,
|
|
101
123
|
};
|
|
102
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* Defence in depth. If an unresolved reference ever reached GramJS it would be
|
|
127
|
+
* sent as the literal string "[object Object]" — a credential-shaped value
|
|
128
|
+
* travelling to a proxy server.
|
|
129
|
+
*/
|
|
130
|
+
function assertProxyResolved(proxy) {
|
|
131
|
+
for (const field of ["username", "password"]) {
|
|
132
|
+
const value = proxy[field];
|
|
133
|
+
if (value !== undefined && typeof value !== "string") {
|
|
134
|
+
throw new Error(`clawgram: proxy.${field} is still an unresolved secret reference.`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
103
138
|
/** Credential-free proxy summary safe to log. */
|
|
104
139
|
function describeProxy(proxy) {
|
|
105
140
|
return proxy ? `socks${proxy.socksType}` : undefined;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Emoji reactions — the `react` action of OpenClaw's message tool.
|
|
4
|
+
*
|
|
5
|
+
* Semantics follow the tool contract, and deliberately match what OpenClaw's
|
|
6
|
+
* own Telegram channel does: an empty `emoji` clears this account's reactions,
|
|
7
|
+
* and `remove: true` also clears but still requires a non-empty `emoji` so the
|
|
8
|
+
* tool call stays self-describing.
|
|
9
|
+
*
|
|
10
|
+
* Parsing lives here, apart from the network, so the argument handling can be
|
|
11
|
+
* tested without a Telegram connection.
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.parseReactionParams = parseReactionParams;
|
|
15
|
+
/** Accepts the boolean and the string a JSON-ish caller may send for it. */
|
|
16
|
+
function readBooleanFlag(value) {
|
|
17
|
+
return value === true || value === "true";
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Message ids are positive integers. A date arriving here would resolve to
|
|
21
|
+
* some unrelated message, so anything else is refused rather than coerced —
|
|
22
|
+
* the same reasoning as the history parser's id/date guard.
|
|
23
|
+
*/
|
|
24
|
+
function parseMessageId(value) {
|
|
25
|
+
if (value === undefined || value === null || value === "") {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
const parsed = Number(value);
|
|
29
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
30
|
+
throw new Error(`clawgram: react messageId must be a positive integer, got ${JSON.stringify(value)}`);
|
|
31
|
+
}
|
|
32
|
+
return parsed;
|
|
33
|
+
}
|
|
34
|
+
function parseReactionParams(params, toolContext) {
|
|
35
|
+
const rawTarget = params.chatId ?? params.target ?? params.to ?? params.chat ?? toolContext?.currentChannelId;
|
|
36
|
+
const target = typeof rawTarget === "string" ? rawTarget.trim() : "";
|
|
37
|
+
if (!target) {
|
|
38
|
+
throw new Error("clawgram: react requires a chatId");
|
|
39
|
+
}
|
|
40
|
+
const messageId = parseMessageId(params.messageId ?? params.msgId ?? params.message_id)
|
|
41
|
+
?? parseMessageId(toolContext?.currentMessageId);
|
|
42
|
+
if (messageId === undefined) {
|
|
43
|
+
throw new Error("clawgram: react requires a messageId");
|
|
44
|
+
}
|
|
45
|
+
const rawEmoji = params.emoji;
|
|
46
|
+
if (rawEmoji !== undefined && rawEmoji !== null && typeof rawEmoji !== "string") {
|
|
47
|
+
throw new Error("clawgram: react emoji must be a string");
|
|
48
|
+
}
|
|
49
|
+
const removeFlag = readBooleanFlag(params.remove);
|
|
50
|
+
const emoji = typeof rawEmoji === "string" ? rawEmoji.trim() : undefined;
|
|
51
|
+
// Only two shapes are meaningful: add this emoji, or clear. A call with
|
|
52
|
+
// neither is a caller mistake, not an empty-string removal.
|
|
53
|
+
if (emoji === undefined) {
|
|
54
|
+
throw new Error("clawgram: react requires an emoji");
|
|
55
|
+
}
|
|
56
|
+
if (removeFlag && emoji === "") {
|
|
57
|
+
throw new Error("clawgram: react with remove requires an emoji");
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
target,
|
|
61
|
+
messageId,
|
|
62
|
+
emoji,
|
|
63
|
+
remove: removeFlag || emoji === "",
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* SecretRef support for the account's credentials.
|
|
4
|
+
*
|
|
5
|
+
* `apiHash` and `sessionString` are bearer credentials for the whole Telegram
|
|
6
|
+
* account, and until now they could only live as plaintext in `openclaw.json` —
|
|
7
|
+
* a file that gets backed up, copied between machines and pasted into issues.
|
|
8
|
+
* Every other secret in this deployment is a reference resolved at start-up;
|
|
9
|
+
* these were the exception. Proxy credentials are here for the same reason.
|
|
10
|
+
*
|
|
11
|
+
* A reference is `{ source, provider, id }`, and OpenClaw resolves a batch of
|
|
12
|
+
* them into a map keyed by `source:provider:id`. Collecting and substituting is
|
|
13
|
+
* kept pure so it can be tested without a secret store; the resolution call
|
|
14
|
+
* itself lives in the channel's account start-up.
|
|
15
|
+
*
|
|
16
|
+
* Nothing here logs a value, and an unresolved reference is reported by field
|
|
17
|
+
* name only. A missing secret is a configuration error, and the error message
|
|
18
|
+
* for it must not become the leak it was meant to prevent.
|
|
19
|
+
*/
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.secretRefKey = secretRefKey;
|
|
22
|
+
exports.collectAccountSecretRefs = collectAccountSecretRefs;
|
|
23
|
+
exports.applyAccountSecrets = applyAccountSecrets;
|
|
24
|
+
exports.hasUnresolvedSecretRef = hasUnresolvedSecretRef;
|
|
25
|
+
exports.readSecretInput = readSecretInput;
|
|
26
|
+
/** Credential fields that accept a reference, in the order they are reported. */
|
|
27
|
+
const ACCOUNT_SECRET_FIELDS = ["apiHash", "sessionString"];
|
|
28
|
+
const PROXY_SECRET_FIELDS = ["username", "password"];
|
|
29
|
+
/**
|
|
30
|
+
* Must match OpenClaw's own keying, or every lookup misses and a correctly
|
|
31
|
+
* configured secret looks unresolvable.
|
|
32
|
+
*/
|
|
33
|
+
function secretRefKey(ref) {
|
|
34
|
+
return `${ref.source}:${ref.provider}:${ref.id}`;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Shape check only. A partially written reference is treated as "not a
|
|
38
|
+
* reference" rather than as an error, so a typo cannot be mistaken for a
|
|
39
|
+
* resolvable secret and silently blank a credential.
|
|
40
|
+
*/
|
|
41
|
+
function asSecretRef(value) {
|
|
42
|
+
if (!value || typeof value !== "object") {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
const candidate = value;
|
|
46
|
+
const { source, provider, id } = candidate;
|
|
47
|
+
if (typeof source !== "string" || typeof provider !== "string" || typeof id !== "string") {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
if (!source.trim() || !provider.trim() || !id.trim()) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
return { source, provider, id };
|
|
54
|
+
}
|
|
55
|
+
/** Every credential slot, as `[path, value]` pairs — account fields and proxy alike. */
|
|
56
|
+
function* eachSecretSlot(account) {
|
|
57
|
+
for (const field of ACCOUNT_SECRET_FIELDS) {
|
|
58
|
+
yield [field, account?.[field]];
|
|
59
|
+
}
|
|
60
|
+
for (const field of PROXY_SECRET_FIELDS) {
|
|
61
|
+
yield [`proxy.${field}`, account?.proxy?.[field]];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* References this account needs resolved, deduplicated: two fields pointing at
|
|
66
|
+
* the same secret must not become two lookups.
|
|
67
|
+
*/
|
|
68
|
+
function collectAccountSecretRefs(account) {
|
|
69
|
+
const byKey = new Map();
|
|
70
|
+
for (const [, value] of eachSecretSlot(account)) {
|
|
71
|
+
const ref = asSecretRef(value);
|
|
72
|
+
if (ref) {
|
|
73
|
+
byKey.set(secretRefKey(ref), ref);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return [...byKey.values()];
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Substitutes resolved values into a copy of the account.
|
|
80
|
+
*
|
|
81
|
+
* A value that did not resolve is left as the reference object and its field
|
|
82
|
+
* named in `missing`. Writing the reference through as a string would produce
|
|
83
|
+
* "[object Object]", which Telegram rejects with a complaint about the
|
|
84
|
+
* credential itself — sending whoever reads it to check a secret that was
|
|
85
|
+
* never the problem.
|
|
86
|
+
*/
|
|
87
|
+
function applyAccountSecrets(account, values) {
|
|
88
|
+
const missing = [];
|
|
89
|
+
const resolved = { ...account };
|
|
90
|
+
if (account?.proxy && typeof account.proxy === "object") {
|
|
91
|
+
resolved.proxy = { ...account.proxy };
|
|
92
|
+
}
|
|
93
|
+
for (const field of ACCOUNT_SECRET_FIELDS) {
|
|
94
|
+
const ref = asSecretRef(account?.[field]);
|
|
95
|
+
if (!ref) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const value = values.get(secretRefKey(ref));
|
|
99
|
+
if (typeof value === "string") {
|
|
100
|
+
resolved[field] = value;
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
missing.push(field);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
for (const field of PROXY_SECRET_FIELDS) {
|
|
107
|
+
const ref = asSecretRef(account?.proxy?.[field]);
|
|
108
|
+
if (!ref) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const value = values.get(secretRefKey(ref));
|
|
112
|
+
if (typeof value === "string") {
|
|
113
|
+
resolved.proxy[field] = value;
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
missing.push(`proxy.${field}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { account: resolved, missing };
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* True while any credential is still a reference. Used as a last gate before
|
|
123
|
+
* handing the account to GramJS, where an unresolved value would travel into a
|
|
124
|
+
* login attempt.
|
|
125
|
+
*/
|
|
126
|
+
function hasUnresolvedSecretRef(account) {
|
|
127
|
+
for (const [, value] of eachSecretSlot(account)) {
|
|
128
|
+
if (asSecretRef(value)) {
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Reads a credential straight out of the config.
|
|
136
|
+
*
|
|
137
|
+
* A reference is returned untouched, for resolution later; everything else is
|
|
138
|
+
* stringified exactly as the config reader always did. The point is the first
|
|
139
|
+
* half: `String(ref)` yields "[object Object]", which travels into a Telegram
|
|
140
|
+
* login and returns as a complaint about the credential rather than about the
|
|
141
|
+
* secret that failed to resolve.
|
|
142
|
+
*/
|
|
143
|
+
function readSecretInput(value) {
|
|
144
|
+
const ref = asSecretRef(value);
|
|
145
|
+
if (ref) {
|
|
146
|
+
return ref;
|
|
147
|
+
}
|
|
148
|
+
return String(value ?? "");
|
|
149
|
+
}
|
package/openclaw.plugin.json
CHANGED
|
@@ -99,10 +99,72 @@
|
|
|
99
99
|
]
|
|
100
100
|
},
|
|
101
101
|
"apiHash": {
|
|
102
|
-
"
|
|
102
|
+
"description": "Literal value, or a SecretRef resolved at account start-up.",
|
|
103
|
+
"anyOf": [
|
|
104
|
+
{
|
|
105
|
+
"type": "string"
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"type": "object",
|
|
109
|
+
"required": [
|
|
110
|
+
"source",
|
|
111
|
+
"provider",
|
|
112
|
+
"id"
|
|
113
|
+
],
|
|
114
|
+
"additionalProperties": false,
|
|
115
|
+
"properties": {
|
|
116
|
+
"source": {
|
|
117
|
+
"enum": [
|
|
118
|
+
"env",
|
|
119
|
+
"file",
|
|
120
|
+
"exec"
|
|
121
|
+
]
|
|
122
|
+
},
|
|
123
|
+
"provider": {
|
|
124
|
+
"type": "string",
|
|
125
|
+
"pattern": "^[a-z][a-z0-9_-]{0,63}$"
|
|
126
|
+
},
|
|
127
|
+
"id": {
|
|
128
|
+
"type": "string",
|
|
129
|
+
"minLength": 1
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
]
|
|
103
134
|
},
|
|
104
135
|
"sessionString": {
|
|
105
|
-
"
|
|
136
|
+
"description": "Literal value, or a SecretRef resolved at account start-up.",
|
|
137
|
+
"anyOf": [
|
|
138
|
+
{
|
|
139
|
+
"type": "string"
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
"type": "object",
|
|
143
|
+
"required": [
|
|
144
|
+
"source",
|
|
145
|
+
"provider",
|
|
146
|
+
"id"
|
|
147
|
+
],
|
|
148
|
+
"additionalProperties": false,
|
|
149
|
+
"properties": {
|
|
150
|
+
"source": {
|
|
151
|
+
"enum": [
|
|
152
|
+
"env",
|
|
153
|
+
"file",
|
|
154
|
+
"exec"
|
|
155
|
+
]
|
|
156
|
+
},
|
|
157
|
+
"provider": {
|
|
158
|
+
"type": "string",
|
|
159
|
+
"pattern": "^[a-z][a-z0-9_-]{0,63}$"
|
|
160
|
+
},
|
|
161
|
+
"id": {
|
|
162
|
+
"type": "string",
|
|
163
|
+
"minLength": 1
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
]
|
|
106
168
|
},
|
|
107
169
|
"proxy": {
|
|
108
170
|
"type": "object",
|
|
@@ -126,10 +188,72 @@
|
|
|
126
188
|
]
|
|
127
189
|
},
|
|
128
190
|
"username": {
|
|
129
|
-
"
|
|
191
|
+
"description": "Literal value, or a SecretRef resolved at account start-up.",
|
|
192
|
+
"anyOf": [
|
|
193
|
+
{
|
|
194
|
+
"type": "string"
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
"type": "object",
|
|
198
|
+
"required": [
|
|
199
|
+
"source",
|
|
200
|
+
"provider",
|
|
201
|
+
"id"
|
|
202
|
+
],
|
|
203
|
+
"additionalProperties": false,
|
|
204
|
+
"properties": {
|
|
205
|
+
"source": {
|
|
206
|
+
"enum": [
|
|
207
|
+
"env",
|
|
208
|
+
"file",
|
|
209
|
+
"exec"
|
|
210
|
+
]
|
|
211
|
+
},
|
|
212
|
+
"provider": {
|
|
213
|
+
"type": "string",
|
|
214
|
+
"pattern": "^[a-z][a-z0-9_-]{0,63}$"
|
|
215
|
+
},
|
|
216
|
+
"id": {
|
|
217
|
+
"type": "string",
|
|
218
|
+
"minLength": 1
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
]
|
|
130
223
|
},
|
|
131
224
|
"password": {
|
|
132
|
-
"
|
|
225
|
+
"description": "Literal value, or a SecretRef resolved at account start-up.",
|
|
226
|
+
"anyOf": [
|
|
227
|
+
{
|
|
228
|
+
"type": "string"
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
"type": "object",
|
|
232
|
+
"required": [
|
|
233
|
+
"source",
|
|
234
|
+
"provider",
|
|
235
|
+
"id"
|
|
236
|
+
],
|
|
237
|
+
"additionalProperties": false,
|
|
238
|
+
"properties": {
|
|
239
|
+
"source": {
|
|
240
|
+
"enum": [
|
|
241
|
+
"env",
|
|
242
|
+
"file",
|
|
243
|
+
"exec"
|
|
244
|
+
]
|
|
245
|
+
},
|
|
246
|
+
"provider": {
|
|
247
|
+
"type": "string",
|
|
248
|
+
"pattern": "^[a-z][a-z0-9_-]{0,63}$"
|
|
249
|
+
},
|
|
250
|
+
"id": {
|
|
251
|
+
"type": "string",
|
|
252
|
+
"minLength": 1
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
]
|
|
133
257
|
},
|
|
134
258
|
"timeout": {
|
|
135
259
|
"type": "number",
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clawgram",
|
|
3
|
-
"version": "2.1
|
|
3
|
+
"version": "2.2.1",
|
|
4
4
|
"description": "Clawgram — personal Telegram (MTProto userbot) channel for OpenClaw. Your AI assistant reads and responds as you.",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"build": "tsc -p tsconfig.json",
|
|
8
8
|
"build:test": "tsc -p tsconfig.test.json",
|
|
9
9
|
"test": "npm run build:test && node test/ensure-compiled.mjs && node --test \"dist-test/test/*.test.js\"",
|
|
10
|
+
"prepublishOnly": "npm run build && npm test",
|
|
10
11
|
"clawgram-cli": "node dist/clawgram-cli.js",
|
|
11
12
|
"clawgram-cli:hello": "node dist/clawgram-cli.js --hello",
|
|
12
13
|
"clawgram-cli:auth": "node dist/clawgram-cli.js --auth"
|