@aspalagin/openclaw-max 0.7.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/LICENSE +21 -0
- package/README.md +424 -0
- package/SKILL.md +108 -0
- package/dist/index.js +17 -0
- package/dist/setup-entry.js +8 -0
- package/dist/src/accounts.js +148 -0
- package/dist/src/actions.js +421 -0
- package/dist/src/api.js +553 -0
- package/dist/src/callbacks.js +111 -0
- package/dist/src/channel-agent-prompt.js +38 -0
- package/dist/src/channel-config.js +103 -0
- package/dist/src/channel-directory.js +84 -0
- package/dist/src/channel-lifecycle.js +219 -0
- package/dist/src/channel-outbound.js +133 -0
- package/dist/src/channel-policy.js +130 -0
- package/dist/src/channel.js +76 -0
- package/dist/src/config-schema.js +133 -0
- package/dist/src/deliver.js +126 -0
- package/dist/src/dispatch.js +186 -0
- package/dist/src/format.js +31 -0
- package/dist/src/inbound-attachments.js +125 -0
- package/dist/src/inbound.js +362 -0
- package/dist/src/media-temp.js +92 -0
- package/dist/src/model-buttons.js +96 -0
- package/dist/src/monitor-types.js +27 -0
- package/dist/src/monitor.js +40 -0
- package/dist/src/onboarding.js +180 -0
- package/dist/src/polling.js +98 -0
- package/dist/src/presentation.js +374 -0
- package/dist/src/runtime.js +36 -0
- package/dist/src/russian-trusted-ca.js +84 -0
- package/dist/src/send.js +612 -0
- package/dist/src/state.js +111 -0
- package/dist/src/sticker-cache.js +26 -0
- package/dist/src/stream-draft.js +102 -0
- package/dist/src/types.js +5 -0
- package/dist/src/webhook-queue.js +215 -0
- package/dist/src/webhook-runner.js +233 -0
- package/dist/src/webhook.js +196 -0
- package/openclaw.plugin.json +263 -0
- package/package.json +100 -0
- package/scripts/check-cycles.mjs +38 -0
- package/scripts/test-api.mjs +169 -0
- package/scripts/test-send.mjs +162 -0
- package/scripts/update-schema.mjs +48 -0
- package/src/accounts.ts +193 -0
- package/src/actions.ts +501 -0
- package/src/api.ts +758 -0
- package/src/callbacks.ts +140 -0
- package/src/channel-agent-prompt.ts +47 -0
- package/src/channel-config.ts +138 -0
- package/src/channel-directory.ts +90 -0
- package/src/channel-lifecycle.ts +256 -0
- package/src/channel-outbound.ts +159 -0
- package/src/channel-policy.ts +185 -0
- package/src/channel.ts +99 -0
- package/src/config-schema.ts +154 -0
- package/src/deliver.ts +168 -0
- package/src/dispatch.ts +224 -0
- package/src/format.ts +33 -0
- package/src/inbound-attachments.ts +152 -0
- package/src/inbound.ts +412 -0
- package/src/media-temp.ts +103 -0
- package/src/model-buttons.ts +150 -0
- package/src/monitor-types.ts +61 -0
- package/src/monitor.ts +49 -0
- package/src/onboarding.ts +215 -0
- package/src/polling.ts +125 -0
- package/src/presentation.ts +452 -0
- package/src/runtime.ts +56 -0
- package/src/russian-trusted-ca.ts +86 -0
- package/src/send.ts +774 -0
- package/src/state.ts +149 -0
- package/src/sticker-cache.ts +28 -0
- package/src/stream-draft.ts +125 -0
- package/src/types.ts +263 -0
- package/src/webhook-queue.ts +248 -0
- package/src/webhook-runner.ts +296 -0
- package/src/webhook.ts +268 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent prompt hints of the MAX channel: stickers, location, contact, pin,
|
|
3
|
+
* button types and presentation for the message tool.
|
|
4
|
+
*/
|
|
5
|
+
/** message tool hints for MAX-specific actions. */
|
|
6
|
+
export const maxAgentPromptAdapter = {
|
|
7
|
+
messageToolHints: () => {
|
|
8
|
+
// Compact sticker emoji map: top 50 emojis â sticker codes
|
|
9
|
+
// Codes are hex IDs derived from listmax.ru external_id: parseInt(extId).toString(16)
|
|
10
|
+
const emojiMap = 'ð:109550b5 ð:109971b5 ð:10931eb5 ðĨ°:10931eb5 ðĒ:109330b5 ð:109330b5 ðĄ:10941fb5 ðą:109302b5 ðĪ:109308b5 ð:109368b5 ð:109323b5 âĪïļ:10931eb5 ðĨ:b4867ebb ðŠ:c1254bbb ð:10933cb5 ð:10931eb5 ðĪ:109b94b5 ð:10931db5 ð:c14211bb ðī:10936eb5 ðĪ:10941fb5 ðĪŪ:6b8bb ðĪŊ:109302b5 ðģ:109302b5 ðĨģ:10933cb5 ð:b4863ebb ð:b4850cbb ð:11e4c60bb ð
:109550b5 ðĪĢ:109550b5 ð:109d2db5 ð:455b5 ðĪ·:10d5cf5bb ðŦ:10936eb5 ðĐ:10997db5 ðĨš:109356b5 ð:14aae3bb ð:109323b5 ðĪŠ:455b5 ð:11e4dedbb ð:50cb5 ð:10997db5 ð:b48534bb âĻ:11e43b2bb ð:10941fb5 ðĪ:109368b5 ðĪĶ:502b5 ðŽ:5dab4b5 ðĪĐ:5dabfb5 ðķ:2ae2b5';
|
|
11
|
+
// Also try to load full sticker-emoji-map.json for extended catalog
|
|
12
|
+
let extendedHint = '';
|
|
13
|
+
try {
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const candidates = [
|
|
17
|
+
path.join(__dirname, '..', 'sticker-emoji-map.json'),
|
|
18
|
+
path.join(process.cwd(), 'projects', 'openclaw-max', 'sticker-emoji-map.json'),
|
|
19
|
+
];
|
|
20
|
+
for (const p of candidates) {
|
|
21
|
+
if (fs.existsSync(p)) {
|
|
22
|
+
extendedHint = ' Full emojiâsticker map available at: ' + p;
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
catch { }
|
|
28
|
+
return [
|
|
29
|
+
'- MAX stickers: use `message(action="sticker", target="CHAT_ID", stickerId="CODE")`. Pick a sticker code matching the mood from the emoji map below. Each entry is emoji:hexCode.',
|
|
30
|
+
`- Sticker emoji map: ${emojiMap}${extendedHint}`,
|
|
31
|
+
'- MAX location: use `message(action="sendAttachment", target="CHAT_ID", type="location", latitude="55.75", longitude="37.62")` to send a native map pin.',
|
|
32
|
+
'- MAX contact: use `message(action="sendAttachment", target="CHAT_ID", type="contact", contactName="Name", vcfPhone="+70001234567")` to send a native contact card.',
|
|
33
|
+
'- MAX pin: use `message(action="pin", target="CHAT_ID", messageId="MID")` to pin a message; `message(action="unpin", target="CHAT_ID")` to unpin.',
|
|
34
|
+
'- MAX buttons support types: callback (default), link, message (sends the button text as a user message â great for suggested replies), clipboard (copies payload), open_app (webApp = public name of the bot wired to the mini app), request_contact, request_geo_location. Pass via buttons=[[{"text":"...","type":"message"}]].',
|
|
35
|
+
'- MAX rich messages: prefer `presentation` (title/tone, text, context, divider, table/chart as monospace text, buttons and select as an inline keyboard); `pin=true` pins the sent message.',
|
|
36
|
+
];
|
|
37
|
+
},
|
|
38
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Account config and CLI setup adapters of the MAX channel: account listing
|
|
3
|
+
* and resolution, enable/delete, allowFrom formatting, `openclaw channels add`.
|
|
4
|
+
*/
|
|
5
|
+
import { applyAccountNameToChannelSection, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, migrateBaseNameToDefaultAccount, normalizeAccountId, setAccountEnabledInConfigSection, } from 'openclaw/plugin-sdk/core';
|
|
6
|
+
import { listMaxAccountIds, resolveMaxAccount } from './accounts.js';
|
|
7
|
+
/** Accounts under channels.max (top level = default account, plus channels.max.accounts). */
|
|
8
|
+
export const maxConfigAdapter = {
|
|
9
|
+
listAccountIds: (cfg) => listMaxAccountIds(cfg),
|
|
10
|
+
resolveAccount: (cfg, accountId) => resolveMaxAccount({ cfg, accountId }),
|
|
11
|
+
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
12
|
+
setAccountEnabled: ({ cfg, accountId, enabled }) => setAccountEnabledInConfigSection({
|
|
13
|
+
cfg,
|
|
14
|
+
sectionKey: 'max',
|
|
15
|
+
accountId,
|
|
16
|
+
enabled,
|
|
17
|
+
allowTopLevel: true,
|
|
18
|
+
}),
|
|
19
|
+
deleteAccount: ({ cfg, accountId }) => deleteAccountFromConfigSection({
|
|
20
|
+
cfg,
|
|
21
|
+
sectionKey: 'max',
|
|
22
|
+
accountId,
|
|
23
|
+
clearBaseFields: ['botToken', 'tokenFile', 'name'],
|
|
24
|
+
}),
|
|
25
|
+
isConfigured: (account) => Boolean(account.token?.trim()),
|
|
26
|
+
describeAccount: (account) => ({
|
|
27
|
+
accountId: account.accountId,
|
|
28
|
+
name: account.name,
|
|
29
|
+
enabled: account.enabled,
|
|
30
|
+
configured: Boolean(account.token?.trim()),
|
|
31
|
+
tokenSource: account.tokenSource,
|
|
32
|
+
}),
|
|
33
|
+
resolveAllowFrom: ({ cfg, accountId }) => (resolveMaxAccount({ cfg, accountId }).config.allowFrom ?? []).map(String),
|
|
34
|
+
formatAllowFrom: ({ allowFrom }) => allowFrom
|
|
35
|
+
.map((entry) => String(entry).trim())
|
|
36
|
+
.filter(Boolean)
|
|
37
|
+
.map((entry) => entry.replace(/^max:/i, '')),
|
|
38
|
+
};
|
|
39
|
+
/** Non-interactive setup: token / token file / MAX_BOT_TOKEN for the default account. */
|
|
40
|
+
export const maxSetupAdapter = {
|
|
41
|
+
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
|
42
|
+
applyAccountName: ({ cfg, accountId, name }) => applyAccountNameToChannelSection({
|
|
43
|
+
cfg,
|
|
44
|
+
channelKey: 'max',
|
|
45
|
+
accountId,
|
|
46
|
+
name,
|
|
47
|
+
}),
|
|
48
|
+
validateInput: ({ accountId, input }) => {
|
|
49
|
+
if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
|
|
50
|
+
return 'MAX_BOT_TOKEN can only be used for the default account.';
|
|
51
|
+
}
|
|
52
|
+
if (!input.useEnv && !input.token && !input.tokenFile) {
|
|
53
|
+
return 'MAX requires --token or --token-file (or --use-env with MAX_BOT_TOKEN).';
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
},
|
|
57
|
+
applyAccountConfig: ({ cfg, accountId, input }) => {
|
|
58
|
+
const namedConfig = applyAccountNameToChannelSection({
|
|
59
|
+
cfg,
|
|
60
|
+
channelKey: 'max',
|
|
61
|
+
accountId,
|
|
62
|
+
name: input.name,
|
|
63
|
+
});
|
|
64
|
+
const next = accountId !== DEFAULT_ACCOUNT_ID
|
|
65
|
+
? migrateBaseNameToDefaultAccount({
|
|
66
|
+
cfg: namedConfig,
|
|
67
|
+
channelKey: 'max',
|
|
68
|
+
})
|
|
69
|
+
: namedConfig;
|
|
70
|
+
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
71
|
+
return {
|
|
72
|
+
...next,
|
|
73
|
+
channels: {
|
|
74
|
+
...next.channels,
|
|
75
|
+
max: {
|
|
76
|
+
...next.channels?.max,
|
|
77
|
+
enabled: true,
|
|
78
|
+
...(input.useEnv ? {} : input.token ? { botToken: input.token } : {}),
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const maxSection = next.channels?.max ?? {};
|
|
84
|
+
return {
|
|
85
|
+
...next,
|
|
86
|
+
channels: {
|
|
87
|
+
...next.channels,
|
|
88
|
+
max: {
|
|
89
|
+
...maxSection,
|
|
90
|
+
enabled: true,
|
|
91
|
+
accounts: {
|
|
92
|
+
...maxSection.accounts,
|
|
93
|
+
[accountId]: {
|
|
94
|
+
...maxSection.accounts?.[accountId],
|
|
95
|
+
enabled: true,
|
|
96
|
+
...(input.token ? { botToken: input.token } : {}),
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
},
|
|
103
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Directory and addressing of the MAX channel: self, peers from allowFrom,
|
|
3
|
+
* groups from the passive chat registry, numeric target normalization.
|
|
4
|
+
*/
|
|
5
|
+
import { resolveMaxAccount } from './accounts.js';
|
|
6
|
+
import { MaxApi } from './api.js';
|
|
7
|
+
import { loadMaxAccountState } from './state.js';
|
|
8
|
+
/** `user:<id>` addresses a user (DM); a bare number is a chat id. */
|
|
9
|
+
const MAX_TARGET_PATTERN = /^(?:user:\d+|-?\d+)$/;
|
|
10
|
+
function stripMaxPrefix(raw) {
|
|
11
|
+
const trimmed = raw.trim();
|
|
12
|
+
return trimmed.startsWith('max:') ? trimmed.slice(4) : trimmed;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Targets are numeric chat ids or `user:<id>`, with an optional max: prefix.
|
|
16
|
+
* The `user:` prefix must survive normalization: dropping it turns a user id
|
|
17
|
+
* into a chat_id and MAX answers dialog.not.found.
|
|
18
|
+
*/
|
|
19
|
+
export const maxMessagingAdapter = {
|
|
20
|
+
normalizeTarget: (raw) => {
|
|
21
|
+
const normalized = stripMaxPrefix(raw);
|
|
22
|
+
return MAX_TARGET_PATTERN.test(normalized) ? normalized : undefined;
|
|
23
|
+
},
|
|
24
|
+
targetResolver: {
|
|
25
|
+
looksLikeId: (raw) => MAX_TARGET_PATTERN.test(stripMaxPrefix(raw)),
|
|
26
|
+
hint: '<chatId|user:userId>',
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
/** MAX has no member/chat listing for bots: peers come from allowFrom, groups from the chat registry. */
|
|
30
|
+
export const maxDirectoryAdapter = {
|
|
31
|
+
self: async ({ cfg, accountId }) => {
|
|
32
|
+
const account = resolveMaxAccount({ cfg, accountId });
|
|
33
|
+
if (!account.token)
|
|
34
|
+
return null;
|
|
35
|
+
try {
|
|
36
|
+
const api = new MaxApi({ token: account.token, timeoutMs: 3000 });
|
|
37
|
+
const me = await api.getMe();
|
|
38
|
+
return {
|
|
39
|
+
kind: 'user',
|
|
40
|
+
id: String(me.user_id),
|
|
41
|
+
name: me.first_name || undefined,
|
|
42
|
+
handle: me.username || undefined,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
listPeers: async ({ cfg, accountId }) => {
|
|
50
|
+
const account = resolveMaxAccount({ cfg, accountId });
|
|
51
|
+
// MAX doesn't expose a full user list API. Return peers from allowFrom config.
|
|
52
|
+
const allowFrom = account.config.allowFrom ?? [];
|
|
53
|
+
return allowFrom.map((id) => ({
|
|
54
|
+
kind: 'user',
|
|
55
|
+
id: String(id),
|
|
56
|
+
name: undefined,
|
|
57
|
+
}));
|
|
58
|
+
},
|
|
59
|
+
listGroups: async ({ cfg, accountId }) => {
|
|
60
|
+
const account = resolveMaxAccount({ cfg, accountId });
|
|
61
|
+
if (!account.token)
|
|
62
|
+
return [];
|
|
63
|
+
const groups = new Map();
|
|
64
|
+
// Ð ÐĩÐĩŅŅŅ ŅаŅÐūÐē ÐŋÐūÐŋÐūÐŧÐ―ŅÐĩŅŅŅ ŅÐūÐąŅŅÐļŅОÐļ bot_added, chat_title_changed Ðļ ŅÐūÐūÐąŅÐĩÐ―ÐļŅОÐļ ÐģŅŅÐŋÐŋ.
|
|
65
|
+
try {
|
|
66
|
+
const state = await loadMaxAccountState(account.accountId);
|
|
67
|
+
for (const entry of Object.values(state.chats ?? {})) {
|
|
68
|
+
if (entry.removedAt)
|
|
69
|
+
continue;
|
|
70
|
+
if (entry.type !== 'chat' && entry.type !== 'channel')
|
|
71
|
+
continue;
|
|
72
|
+
groups.set(String(entry.chatId), {
|
|
73
|
+
kind: entry.type === 'channel' ? 'channel' : 'group',
|
|
74
|
+
id: String(entry.chatId),
|
|
75
|
+
name: entry.title || undefined,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// ÐŅÐļ Ð―ÐĩÐīÐūŅŅŅÐŋÐ―ÐūŅŅÐļ ŅÐĩÐĩŅŅŅа ÐēÐĩŅÐ―ŅŅŅ ÐŋŅŅŅÐūÐđ ŅÐŋÐļŅÐūК.
|
|
81
|
+
}
|
|
82
|
+
return [...groups.values()];
|
|
83
|
+
},
|
|
84
|
+
};
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Account lifecycle of the MAX channel: gateway start (probe, bot commands,
|
|
3
|
+
* monitor) and logout, status snapshots, probes and group audits (health).
|
|
4
|
+
*/
|
|
5
|
+
import { DEFAULT_ACCOUNT_ID } from 'openclaw/plugin-sdk/core';
|
|
6
|
+
import { MaxApi } from './api.js';
|
|
7
|
+
import { startMaxPolling } from './monitor.js';
|
|
8
|
+
import { writeMaxConfig } from './runtime.js';
|
|
9
|
+
/** Runtime snapshots, GET /me probe and a membership audit of configured groups. */
|
|
10
|
+
export const maxStatusAdapter = {
|
|
11
|
+
defaultRuntime: {
|
|
12
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
13
|
+
running: false,
|
|
14
|
+
lastStartAt: null,
|
|
15
|
+
lastStopAt: null,
|
|
16
|
+
lastError: null,
|
|
17
|
+
},
|
|
18
|
+
buildChannelSummary: ({ snapshot }) => ({
|
|
19
|
+
configured: snapshot.configured ?? false,
|
|
20
|
+
tokenSource: snapshot.tokenSource ?? 'none',
|
|
21
|
+
running: snapshot.running ?? false,
|
|
22
|
+
// lifecycle/connected must mirror the gateway runtime store: the health cache
|
|
23
|
+
// compares them with the live runtime and treats a missing value as stale.
|
|
24
|
+
...(snapshot.lifecycle !== undefined ? { lifecycle: snapshot.lifecycle } : {}),
|
|
25
|
+
...(typeof snapshot.connected === 'boolean' ? { connected: snapshot.connected } : {}),
|
|
26
|
+
lastStartAt: snapshot.lastStartAt ?? null,
|
|
27
|
+
lastStopAt: snapshot.lastStopAt ?? null,
|
|
28
|
+
lastError: snapshot.lastError ?? null,
|
|
29
|
+
probe: snapshot.probe,
|
|
30
|
+
}),
|
|
31
|
+
probeAccount: async ({ account, timeoutMs }) => {
|
|
32
|
+
if (!account.token)
|
|
33
|
+
return { ok: false, error: 'no token' };
|
|
34
|
+
const api = new MaxApi({ token: account.token, timeoutMs });
|
|
35
|
+
try {
|
|
36
|
+
const me = await api.getMe();
|
|
37
|
+
return { ok: true, bot: me };
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
return { ok: false, error: String(err) };
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
|
44
|
+
accountId: account.accountId,
|
|
45
|
+
name: account.name,
|
|
46
|
+
enabled: account.enabled,
|
|
47
|
+
configured: Boolean(account.token?.trim()),
|
|
48
|
+
tokenSource: account.tokenSource,
|
|
49
|
+
running: runtime?.running ?? false,
|
|
50
|
+
...(runtime?.lifecycle !== undefined ? { lifecycle: runtime.lifecycle } : {}),
|
|
51
|
+
...(typeof runtime?.connected === 'boolean' ? { connected: runtime.connected } : {}),
|
|
52
|
+
lastStartAt: runtime?.lastStartAt ?? null,
|
|
53
|
+
lastStopAt: runtime?.lastStopAt ?? null,
|
|
54
|
+
lastError: runtime?.lastError ?? null,
|
|
55
|
+
probe,
|
|
56
|
+
lastInboundAt: runtime?.lastInboundAt ?? null,
|
|
57
|
+
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
|
58
|
+
}),
|
|
59
|
+
auditAccount: async ({ account, timeoutMs }) => {
|
|
60
|
+
if (!account.token) {
|
|
61
|
+
return {
|
|
62
|
+
ok: false,
|
|
63
|
+
checkedGroups: 0,
|
|
64
|
+
unresolvedGroups: 0,
|
|
65
|
+
groups: [],
|
|
66
|
+
elapsedMs: 0,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
const start = Date.now();
|
|
70
|
+
const groups = account.config.groups ?? {};
|
|
71
|
+
const groupIds = Object.keys(groups).filter((id) => id !== '*');
|
|
72
|
+
if (groupIds.length === 0) {
|
|
73
|
+
return {
|
|
74
|
+
ok: true,
|
|
75
|
+
checkedGroups: 0,
|
|
76
|
+
unresolvedGroups: 0,
|
|
77
|
+
groups: [],
|
|
78
|
+
elapsedMs: Date.now() - start,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const api = new MaxApi({ token: account.token, timeoutMs });
|
|
82
|
+
const results = [];
|
|
83
|
+
let unresolvedCount = 0;
|
|
84
|
+
for (const groupId of groupIds) {
|
|
85
|
+
try {
|
|
86
|
+
const chat = await api.getChat(Number(groupId));
|
|
87
|
+
const isMember = chat.type === 'chat' || chat.type === 'channel';
|
|
88
|
+
if (!isMember) {
|
|
89
|
+
unresolvedCount++;
|
|
90
|
+
results.push({
|
|
91
|
+
id: groupId,
|
|
92
|
+
ok: false,
|
|
93
|
+
error: 'Bot is not a member of this chat',
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
// Long polling delivers group updates only to admin bots â surface
|
|
98
|
+
// a missing-admin state, the classic "bot is silent in the group" cause.
|
|
99
|
+
let adminSuffix = '';
|
|
100
|
+
try {
|
|
101
|
+
const membership = await api.getMembership(Number(groupId));
|
|
102
|
+
if (membership && membership.is_admin !== true) {
|
|
103
|
+
adminSuffix = ' (â bot is not admin â group updates are not delivered via polling)';
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// membership endpoint unavailable â skip the admin hint
|
|
108
|
+
}
|
|
109
|
+
results.push({
|
|
110
|
+
id: groupId,
|
|
111
|
+
ok: true,
|
|
112
|
+
title: `${chat.title ?? ''}${adminSuffix}` || undefined,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
unresolvedCount++;
|
|
118
|
+
results.push({
|
|
119
|
+
id: groupId,
|
|
120
|
+
ok: false,
|
|
121
|
+
error: String(err),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
ok: unresolvedCount === 0,
|
|
127
|
+
checkedGroups: groupIds.length,
|
|
128
|
+
unresolvedGroups: unresolvedCount,
|
|
129
|
+
groups: results,
|
|
130
|
+
elapsedMs: Date.now() - start,
|
|
131
|
+
};
|
|
132
|
+
},
|
|
133
|
+
collectStatusIssues: (accounts) => {
|
|
134
|
+
const issues = [];
|
|
135
|
+
for (const snapshot of accounts) {
|
|
136
|
+
if (!snapshot.configured) {
|
|
137
|
+
issues.push({
|
|
138
|
+
channel: 'max',
|
|
139
|
+
accountId: snapshot.accountId,
|
|
140
|
+
kind: 'config',
|
|
141
|
+
message: 'MAX bot token not configured',
|
|
142
|
+
fix: 'Set channels.max.botToken or MAX_BOT_TOKEN env var',
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return issues;
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
/** Starts one account: probe the bot, register commands, run the monitor until abort. */
|
|
150
|
+
export const maxGatewayAdapter = {
|
|
151
|
+
startAccount: async (ctx) => {
|
|
152
|
+
const account = ctx.account;
|
|
153
|
+
const token = account.token.trim();
|
|
154
|
+
let botLabel = '';
|
|
155
|
+
let botUserId;
|
|
156
|
+
let botUsername;
|
|
157
|
+
try {
|
|
158
|
+
const probeApi = new MaxApi({ token, timeoutMs: 3000 });
|
|
159
|
+
const me = await probeApi.getMe();
|
|
160
|
+
if (me.username) {
|
|
161
|
+
botLabel = ` (@${me.username})`;
|
|
162
|
+
botUsername = me.username;
|
|
163
|
+
}
|
|
164
|
+
botUserId = me.user_id;
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// probe failed, continue anyway
|
|
168
|
+
}
|
|
169
|
+
ctx.log?.info(`[${account.accountId}] Starting MAX provider${botLabel}`);
|
|
170
|
+
const api = new MaxApi({ token });
|
|
171
|
+
// Register bot commands if configured
|
|
172
|
+
const commands = ctx.cfg.channels?.max?.commands;
|
|
173
|
+
if (commands?.length) {
|
|
174
|
+
try {
|
|
175
|
+
await api.setMyCommands(commands);
|
|
176
|
+
ctx.log?.info(`[${account.accountId}] Registered ${commands.length} bot commands`);
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
ctx.log?.error(`[${account.accountId}] Failed to register commands: ${String(err)}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return startMaxPolling({
|
|
183
|
+
api,
|
|
184
|
+
account,
|
|
185
|
+
config: ctx.cfg,
|
|
186
|
+
abortSignal: ctx.abortSignal,
|
|
187
|
+
botUserId,
|
|
188
|
+
botUsername,
|
|
189
|
+
log: ctx.log,
|
|
190
|
+
statusSink: (patch) => {
|
|
191
|
+
const current = ctx.getStatus();
|
|
192
|
+
ctx.setStatus({ ...current, ...patch });
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
},
|
|
196
|
+
logoutAccount: async ({ accountId, cfg }) => {
|
|
197
|
+
const nextCfg = { ...cfg };
|
|
198
|
+
const channels = { ...nextCfg.channels };
|
|
199
|
+
const maxSection = channels.max ? { ...channels.max } : undefined;
|
|
200
|
+
let cleared = false;
|
|
201
|
+
if (maxSection) {
|
|
202
|
+
if (accountId === DEFAULT_ACCOUNT_ID && maxSection.botToken) {
|
|
203
|
+
delete maxSection.botToken;
|
|
204
|
+
cleared = true;
|
|
205
|
+
}
|
|
206
|
+
const accounts = maxSection.accounts;
|
|
207
|
+
if (accounts && accountId in accounts) {
|
|
208
|
+
delete accounts[accountId];
|
|
209
|
+
cleared = true;
|
|
210
|
+
}
|
|
211
|
+
channels.max = maxSection;
|
|
212
|
+
nextCfg.channels = channels;
|
|
213
|
+
if (cleared) {
|
|
214
|
+
await writeMaxConfig(nextCfg);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return { cleared, loggedOut: cleared };
|
|
218
|
+
},
|
|
219
|
+
};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbound adapter of the MAX channel: sendText / sendPayload / sendMedia,
|
|
3
|
+
* presentation rendering and delivery pins for core's outbound path.
|
|
4
|
+
*/
|
|
5
|
+
import { resolveMaxAccount } from './accounts.js';
|
|
6
|
+
import { materializeMaxPresentation, MAX_PRESENTATION_CAPABILITIES, MAX_TEXT_LIMIT, renderMaxPresentation, } from './presentation.js';
|
|
7
|
+
import { getMaxRuntime, loadMaxConfig } from './runtime.js';
|
|
8
|
+
import { pinMaxMessage, readMaxChannelButtons, sendMaxMediaGroup, sendMaxMediaMessage, sendMaxMessage, } from './send.js';
|
|
9
|
+
/** Direct delivery with 4000-character markdown chunks. */
|
|
10
|
+
export const maxOutboundAdapter = {
|
|
11
|
+
deliveryMode: 'direct',
|
|
12
|
+
chunker: (text, limit) => getMaxRuntime().channel.text.chunkMarkdownText(text, limit),
|
|
13
|
+
chunkerMode: 'markdown',
|
|
14
|
+
textChunkLimit: 4000,
|
|
15
|
+
// Presentation (docs/plugins/message-presentation.md): core adapts to these
|
|
16
|
+
// capabilities, renders through renderPresentation and pins through
|
|
17
|
+
// pinDeliveredMessage after the first delivered message.
|
|
18
|
+
presentationCapabilities: MAX_PRESENTATION_CAPABILITIES,
|
|
19
|
+
deliveryCapabilities: { pin: true },
|
|
20
|
+
renderPresentation: ({ payload, presentation }) => renderMaxPresentation(payload, presentation),
|
|
21
|
+
pinDeliveredMessage: async ({ cfg, target, messageId, pin, assertDirectAdapterHandoff }) => {
|
|
22
|
+
const account = resolveMaxAccount({ cfg, accountId: target.accountId });
|
|
23
|
+
if (!account.token)
|
|
24
|
+
throw new Error('MAX bot token not configured');
|
|
25
|
+
assertDirectAdapterHandoff?.();
|
|
26
|
+
// Dialogs cannot pin: pinMaxMessage resolves { pinned: false } without an
|
|
27
|
+
// API call or error, so core logs no failed pin for a DM delivery.
|
|
28
|
+
await pinMaxMessage(target.to, messageId, {
|
|
29
|
+
token: account.token,
|
|
30
|
+
pinNotify: pin.notify === true,
|
|
31
|
+
beforeRequest: assertDirectAdapterHandoff,
|
|
32
|
+
});
|
|
33
|
+
},
|
|
34
|
+
sendPayload: async ({ to, text, payload: rawPayload, mediaUrl, accountId, replyToId }) => {
|
|
35
|
+
const cfg = await loadMaxConfig();
|
|
36
|
+
const account = resolveMaxAccount({ cfg, accountId });
|
|
37
|
+
if (!account.token)
|
|
38
|
+
throw new Error('MAX bot token not configured');
|
|
39
|
+
// Core renders presentation before sendPayload; a payload that still
|
|
40
|
+
// carries one came through a path that did not, so apply the same policy.
|
|
41
|
+
const payload = rawPayload.presentation
|
|
42
|
+
? await materializeMaxPresentation(rawPayload)
|
|
43
|
+
: rawPayload;
|
|
44
|
+
const effectiveText = payload === rawPayload ? text : (payload.text ?? '');
|
|
45
|
+
const buttons = readMaxChannelButtons(payload.channelData);
|
|
46
|
+
const mediaUrls = mediaUrl
|
|
47
|
+
? [mediaUrl]
|
|
48
|
+
: payload.mediaUrls?.length
|
|
49
|
+
? payload.mediaUrls
|
|
50
|
+
: payload.mediaUrl
|
|
51
|
+
? [payload.mediaUrl]
|
|
52
|
+
: [];
|
|
53
|
+
if (mediaUrls.length) {
|
|
54
|
+
// Albums: up to 12 images/videos per message, caption on the first,
|
|
55
|
+
// buttons on the last; report the first message (delivery.pin).
|
|
56
|
+
const result = await sendMaxMediaGroup(to, effectiveText, mediaUrls, {
|
|
57
|
+
token: account.token,
|
|
58
|
+
replyToMessageId: replyToId ?? undefined,
|
|
59
|
+
format: 'markdown',
|
|
60
|
+
buttons,
|
|
61
|
+
mediaMaxBytes: (account.config.mediaMaxMb ?? 20) * 1024 * 1024,
|
|
62
|
+
});
|
|
63
|
+
return {
|
|
64
|
+
channel: 'max',
|
|
65
|
+
messageId: result.messageIds[0] ?? '',
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
// MAX caps a message at 4000 characters: split, keyboard on the last
|
|
69
|
+
// chunk, report the first one (delivery.pin pins the first chunk).
|
|
70
|
+
const chunks = effectiveText.length > MAX_TEXT_LIMIT
|
|
71
|
+
? getMaxRuntime().channel.text.chunkMarkdownText(effectiveText, MAX_TEXT_LIMIT)
|
|
72
|
+
: [effectiveText];
|
|
73
|
+
let firstMessageId = '';
|
|
74
|
+
for (let index = 0; index < chunks.length; index += 1) {
|
|
75
|
+
const result = await sendMaxMessage(to, chunks[index], {
|
|
76
|
+
token: account.token,
|
|
77
|
+
replyToMessageId: index === 0 ? (replyToId ?? undefined) : undefined,
|
|
78
|
+
format: 'markdown',
|
|
79
|
+
buttons: index === chunks.length - 1 ? buttons : undefined,
|
|
80
|
+
});
|
|
81
|
+
if (index === 0)
|
|
82
|
+
firstMessageId = result.messageId;
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
channel: 'max',
|
|
86
|
+
messageId: firstMessageId,
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
sendText: async ({ to, text, accountId, replyToId }) => {
|
|
90
|
+
const cfg = await loadMaxConfig();
|
|
91
|
+
const account = resolveMaxAccount({ cfg, accountId });
|
|
92
|
+
if (!account.token)
|
|
93
|
+
throw new Error('MAX bot token not configured');
|
|
94
|
+
const result = await sendMaxMessage(to, text, {
|
|
95
|
+
token: account.token,
|
|
96
|
+
replyToMessageId: replyToId ?? undefined,
|
|
97
|
+
format: 'markdown',
|
|
98
|
+
});
|
|
99
|
+
return {
|
|
100
|
+
channel: 'max',
|
|
101
|
+
messageId: result.messageId,
|
|
102
|
+
};
|
|
103
|
+
},
|
|
104
|
+
sendMedia: async ({ to, text, mediaUrl, accountId, replyToId }) => {
|
|
105
|
+
const cfg = await loadMaxConfig();
|
|
106
|
+
const account = resolveMaxAccount({ cfg, accountId });
|
|
107
|
+
if (!account.token)
|
|
108
|
+
throw new Error('MAX bot token not configured');
|
|
109
|
+
if (!mediaUrl) {
|
|
110
|
+
// No media, send as text
|
|
111
|
+
const result = await sendMaxMessage(to, text, {
|
|
112
|
+
token: account.token,
|
|
113
|
+
replyToMessageId: replyToId ?? undefined,
|
|
114
|
+
format: 'markdown',
|
|
115
|
+
});
|
|
116
|
+
return {
|
|
117
|
+
channel: 'max',
|
|
118
|
+
messageId: result.messageId,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
// Upload and send media
|
|
122
|
+
const result = await sendMaxMediaMessage(to, text, mediaUrl, {
|
|
123
|
+
token: account.token,
|
|
124
|
+
replyToMessageId: replyToId ?? undefined,
|
|
125
|
+
format: 'markdown',
|
|
126
|
+
mediaMaxBytes: (account.config.mediaMaxMb ?? 20) * 1024 * 1024,
|
|
127
|
+
});
|
|
128
|
+
return {
|
|
129
|
+
channel: 'max',
|
|
130
|
+
messageId: result.messageId,
|
|
131
|
+
};
|
|
132
|
+
},
|
|
133
|
+
};
|