Merge branch 'main' into skill/apple-container
This commit is contained in:
@@ -1 +1 @@
|
|||||||
|
TELEGRAM_BOT_TOKEN=
|
||||||
|
|||||||
214
.github/workflows/fork-sync-skills.yml
vendored
Normal file
214
.github/workflows/fork-sync-skills.yml
vendored
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
name: Sync upstream & merge-forward skill branches
|
||||||
|
|
||||||
|
on:
|
||||||
|
# Triggered by upstream repo via repository_dispatch
|
||||||
|
repository_dispatch:
|
||||||
|
types: [upstream-main-updated]
|
||||||
|
# Fallback: run on a schedule in case dispatch isn't configured
|
||||||
|
schedule:
|
||||||
|
- cron: '0 */6 * * *' # every 6 hours
|
||||||
|
# Also run when fork's main is pushed directly
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: fork-sync
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync-and-merge:
|
||||||
|
if: github.repository != 'qwibitai/nanoclaw'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/create-github-app-token@v1
|
||||||
|
id: app-token
|
||||||
|
with:
|
||||||
|
app-id: ${{ secrets.APP_ID }}
|
||||||
|
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||||
|
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ steps.app-token.outputs.token }}
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Configure git
|
||||||
|
run: |
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
|
- name: Sync with upstream main
|
||||||
|
id: sync
|
||||||
|
run: |
|
||||||
|
# Add upstream remote
|
||||||
|
git remote add upstream https://github.com/qwibitai/nanoclaw.git
|
||||||
|
git fetch upstream main
|
||||||
|
|
||||||
|
# Check if upstream has new commits
|
||||||
|
if git merge-base --is-ancestor upstream/main HEAD; then
|
||||||
|
echo "Already up to date with upstream main."
|
||||||
|
echo "synced=false" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Merge upstream main into fork's main
|
||||||
|
if ! git merge upstream/main --no-edit; then
|
||||||
|
echo "::error::Failed to merge upstream/main into fork main — conflicts detected"
|
||||||
|
git merge --abort
|
||||||
|
echo "synced=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "sync_failed=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Validate build
|
||||||
|
npm ci
|
||||||
|
if ! npm run build; then
|
||||||
|
echo "::error::Build failed after merging upstream/main"
|
||||||
|
git reset --hard "origin/main"
|
||||||
|
echo "synced=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "sync_failed=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! npm test 2>/dev/null; then
|
||||||
|
echo "::error::Tests failed after merging upstream/main"
|
||||||
|
git reset --hard "origin/main"
|
||||||
|
echo "synced=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "sync_failed=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
git push origin main
|
||||||
|
echo "synced=true" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Merge main into skill branches
|
||||||
|
id: merge
|
||||||
|
run: |
|
||||||
|
# Re-fetch to pick up any changes pushed since job start
|
||||||
|
git fetch origin
|
||||||
|
|
||||||
|
FAILED=""
|
||||||
|
SUCCEEDED=""
|
||||||
|
|
||||||
|
# List all remote skill branches
|
||||||
|
SKILL_BRANCHES=$(git branch -r --list 'origin/skill/*' | sed 's|origin/||' | xargs)
|
||||||
|
|
||||||
|
if [ -z "$SKILL_BRANCHES" ]; then
|
||||||
|
echo "No skill branches found."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
for BRANCH in $SKILL_BRANCHES; do
|
||||||
|
SKILL_NAME=$(echo "$BRANCH" | sed 's|skill/||')
|
||||||
|
echo ""
|
||||||
|
echo "=== Processing $BRANCH ==="
|
||||||
|
|
||||||
|
git checkout -B "$BRANCH" "origin/$BRANCH"
|
||||||
|
|
||||||
|
if ! git merge main --no-edit; then
|
||||||
|
echo "::warning::Merge conflict in $BRANCH"
|
||||||
|
git merge --abort
|
||||||
|
FAILED="$FAILED $SKILL_NAME"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if there's anything new to push
|
||||||
|
if git diff --quiet "origin/$BRANCH"; then
|
||||||
|
echo "$BRANCH is already up to date with main."
|
||||||
|
SUCCEEDED="$SUCCEEDED $SKILL_NAME"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
if ! npm run build; then
|
||||||
|
echo "::warning::Build failed for $BRANCH"
|
||||||
|
git reset --hard "origin/$BRANCH"
|
||||||
|
FAILED="$FAILED $SKILL_NAME"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! npm test 2>/dev/null; then
|
||||||
|
echo "::warning::Tests failed for $BRANCH"
|
||||||
|
git reset --hard "origin/$BRANCH"
|
||||||
|
FAILED="$FAILED $SKILL_NAME"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
git push origin "$BRANCH"
|
||||||
|
SUCCEEDED="$SUCCEEDED $SKILL_NAME"
|
||||||
|
echo "$BRANCH merged and pushed successfully."
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Results ==="
|
||||||
|
echo "Succeeded: $SUCCEEDED"
|
||||||
|
echo "Failed: $FAILED"
|
||||||
|
|
||||||
|
echo "failed=$FAILED" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "succeeded=$SUCCEEDED" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Open issue for upstream sync failure
|
||||||
|
if: steps.sync.outputs.sync_failed == 'true'
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
await github.rest.issues.create({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
title: `Upstream sync failed — merge conflict or build failure`,
|
||||||
|
body: [
|
||||||
|
'The automated sync with `qwibitai/nanoclaw` main failed.',
|
||||||
|
'',
|
||||||
|
'This usually means upstream made changes that conflict with this fork\'s channel code.',
|
||||||
|
'',
|
||||||
|
'To resolve manually:',
|
||||||
|
'```bash',
|
||||||
|
'git fetch upstream main',
|
||||||
|
'git merge upstream/main',
|
||||||
|
'# resolve conflicts',
|
||||||
|
'npm run build && npm test',
|
||||||
|
'git push',
|
||||||
|
'```',
|
||||||
|
].join('\n'),
|
||||||
|
labels: ['upstream-sync']
|
||||||
|
});
|
||||||
|
|
||||||
|
- name: Open issue for failed skill merges
|
||||||
|
if: steps.merge.outputs.failed != ''
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const failed = '${{ steps.merge.outputs.failed }}'.trim().split(/\s+/);
|
||||||
|
const body = [
|
||||||
|
`The merge-forward workflow failed to merge \`main\` into the following skill branches:`,
|
||||||
|
'',
|
||||||
|
...failed.map(s => `- \`skill/${s}\`: merge conflict, build failure, or test failure`),
|
||||||
|
'',
|
||||||
|
'Please resolve manually:',
|
||||||
|
'```bash',
|
||||||
|
...failed.map(s => [
|
||||||
|
`git checkout skill/${s}`,
|
||||||
|
`git merge main`,
|
||||||
|
`# resolve conflicts, then: git push`,
|
||||||
|
''
|
||||||
|
]).flat(),
|
||||||
|
'```',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
await github.rest.issues.create({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
title: `Merge-forward failed for ${failed.length} skill branch(es)`,
|
||||||
|
body,
|
||||||
|
labels: ['skill-maintenance']
|
||||||
|
});
|
||||||
8
container/agent-runner/package-lock.json
generated
8
container/agent-runner/package-lock.json
generated
@@ -8,7 +8,7 @@
|
|||||||
"name": "nanoclaw-agent-runner",
|
"name": "nanoclaw-agent-runner",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.34",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.76",
|
||||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||||
"cron-parser": "^5.0.0",
|
"cron-parser": "^5.0.0",
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
@@ -19,9 +19,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||||
"version": "0.2.68",
|
"version": "0.2.76",
|
||||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.68.tgz",
|
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.76.tgz",
|
||||||
"integrity": "sha512-y4n6hTTgAqmiV/pqy1G4OgIdg6gDiAKPJaEgO1NOh7/rdsrXyc/HQoUmUy0ty4HkBq1hasm7hB92wtX3W1UMEw==",
|
"integrity": "sha512-HZxvnT8ZWkzCnQygaYCA0dl8RSUzuVbxE1YG4ecy6vh4nQbTT36CxUxBy+QVdR12pPQluncC0mCOLhI2918Eaw==",
|
||||||
"license": "SEE LICENSE IN README.md",
|
"license": "SEE LICENSE IN README.md",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"start": "node dist/index.js"
|
"start": "node dist/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.34",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.76",
|
||||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||||
"cron-parser": "^5.0.0",
|
"cron-parser": "^5.0.0",
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
|
|||||||
112
package-lock.json
generated
112
package-lock.json
generated
@@ -1,15 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "nanoclaw",
|
"name": "nanoclaw",
|
||||||
"version": "1.2.12",
|
"version": "1.2.14",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nanoclaw",
|
"name": "nanoclaw",
|
||||||
"version": "1.2.12",
|
"version": "1.2.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^11.8.1",
|
"better-sqlite3": "^11.8.1",
|
||||||
"cron-parser": "^5.5.0",
|
"cron-parser": "^5.5.0",
|
||||||
|
"grammy": "^1.39.3",
|
||||||
"pino": "^9.6.0",
|
"pino": "^9.6.0",
|
||||||
"pino-pretty": "^13.0.0",
|
"pino-pretty": "^13.0.0",
|
||||||
"yaml": "^2.8.2",
|
"yaml": "^2.8.2",
|
||||||
@@ -531,6 +532,12 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@grammyjs/types": {
|
||||||
|
"version": "3.25.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-3.25.0.tgz",
|
||||||
|
"integrity": "sha512-iN9i5p+8ZOu9OMxWNcguojQfz4K/PDyMPOnL7PPCON+SoA/F8OKMH3uR7CVUkYfdNe0GCz8QOzAWrnqusQYFOg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/resolve-uri": {
|
"node_modules/@jridgewell/resolve-uri": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||||
@@ -1109,6 +1116,18 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/abort-controller": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"event-target-shim": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/assertion-error": {
|
"node_modules/assertion-error": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
@@ -1258,6 +1277,23 @@
|
|||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/debug": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/decompress-response": {
|
"node_modules/decompress-response": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||||
@@ -1359,6 +1395,15 @@
|
|||||||
"@types/estree": "^1.0.0"
|
"@types/estree": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/event-target-shim": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expand-template": {
|
"node_modules/expand-template": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||||
@@ -1454,6 +1499,21 @@
|
|||||||
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/grammy": {
|
||||||
|
"version": "1.41.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/grammy/-/grammy-1.41.1.tgz",
|
||||||
|
"integrity": "sha512-wcHAQ1e7svL3fJMpDchcQVcWUmywhuepOOjHUHmMmWAwUJEIyK5ea5sbSjZd+Gy1aMpZeP8VYJa+4tP+j1YptQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@grammyjs/types": "3.25.0",
|
||||||
|
"abort-controller": "^3.0.0",
|
||||||
|
"debug": "^4.4.3",
|
||||||
|
"node-fetch": "^2.7.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20.0 || >=14.13.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/has-flag": {
|
"node_modules/has-flag": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
@@ -1654,6 +1714,12 @@
|
|||||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
@@ -1691,6 +1757,26 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-fetch": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-url": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "4.x || >=6.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"encoding": "^0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"encoding": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/obug": {
|
"node_modules/obug": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||||
@@ -2288,6 +2374,12 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tr46": {
|
||||||
|
"version": "0.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tsx": {
|
"node_modules/tsx": {
|
||||||
"version": "4.21.0",
|
"version": "4.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||||
@@ -2500,6 +2592,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/webidl-conversions": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-url": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tr46": "~0.0.3",
|
||||||
|
"webidl-conversions": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/why-is-node-running": {
|
"node_modules/why-is-node-running": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nanoclaw",
|
"name": "nanoclaw",
|
||||||
"version": "1.2.12",
|
"version": "1.2.14",
|
||||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^11.8.1",
|
"better-sqlite3": "^11.8.1",
|
||||||
|
"grammy": "^1.39.3",
|
||||||
"cron-parser": "^5.5.0",
|
"cron-parser": "^5.5.0",
|
||||||
"pino": "^9.6.0",
|
"pino": "^9.6.0",
|
||||||
"pino-pretty": "^13.0.0",
|
"pino-pretty": "^13.0.0",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="97" height="20" role="img" aria-label="38.8k tokens, 19% of context window">
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="97" height="20" role="img" aria-label="41.0k tokens, 20% of context window">
|
||||||
<title>38.8k tokens, 19% of context window</title>
|
<title>41.0k tokens, 20% of context window</title>
|
||||||
<linearGradient id="s" x2="0" y2="100%">
|
<linearGradient id="s" x2="0" y2="100%">
|
||||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||||
<stop offset="1" stop-opacity=".1"/>
|
<stop offset="1" stop-opacity=".1"/>
|
||||||
@@ -15,8 +15,8 @@
|
|||||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||||
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
||||||
<text x="26" y="14">tokens</text>
|
<text x="26" y="14">tokens</text>
|
||||||
<text aria-hidden="true" x="74" y="15" fill="#010101" fill-opacity=".3">38.8k</text>
|
<text aria-hidden="true" x="74" y="15" fill="#010101" fill-opacity=".3">41.0k</text>
|
||||||
<text x="74" y="14">38.8k</text>
|
<text x="74" y="14">41.0k</text>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -8,5 +8,6 @@
|
|||||||
// slack
|
// slack
|
||||||
|
|
||||||
// telegram
|
// telegram
|
||||||
|
import './telegram.js';
|
||||||
|
|
||||||
// whatsapp
|
// whatsapp
|
||||||
|
|||||||
936
src/channels/telegram.test.ts
Normal file
936
src/channels/telegram.test.ts
Normal file
@@ -0,0 +1,936 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
// --- Mocks ---
|
||||||
|
|
||||||
|
// Mock registry (registerChannel runs at import time)
|
||||||
|
vi.mock('./registry.js', () => ({ registerChannel: vi.fn() }));
|
||||||
|
|
||||||
|
// Mock env reader (used by the factory, not needed in unit tests)
|
||||||
|
vi.mock('../env.js', () => ({ readEnvFile: vi.fn(() => ({})) }));
|
||||||
|
|
||||||
|
// Mock config
|
||||||
|
vi.mock('../config.js', () => ({
|
||||||
|
ASSISTANT_NAME: 'Andy',
|
||||||
|
TRIGGER_PATTERN: /^@Andy\b/i,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock logger
|
||||||
|
vi.mock('../logger.js', () => ({
|
||||||
|
logger: {
|
||||||
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// --- Grammy mock ---
|
||||||
|
|
||||||
|
type Handler = (...args: any[]) => any;
|
||||||
|
|
||||||
|
const botRef = vi.hoisted(() => ({ current: null as any }));
|
||||||
|
|
||||||
|
vi.mock('grammy', () => ({
|
||||||
|
Bot: class MockBot {
|
||||||
|
token: string;
|
||||||
|
commandHandlers = new Map<string, Handler>();
|
||||||
|
filterHandlers = new Map<string, Handler[]>();
|
||||||
|
errorHandler: Handler | null = null;
|
||||||
|
|
||||||
|
api = {
|
||||||
|
sendMessage: vi.fn().mockResolvedValue(undefined),
|
||||||
|
sendChatAction: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(token: string) {
|
||||||
|
this.token = token;
|
||||||
|
botRef.current = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
command(name: string, handler: Handler) {
|
||||||
|
this.commandHandlers.set(name, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
on(filter: string, handler: Handler) {
|
||||||
|
const existing = this.filterHandlers.get(filter) || [];
|
||||||
|
existing.push(handler);
|
||||||
|
this.filterHandlers.set(filter, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
catch(handler: Handler) {
|
||||||
|
this.errorHandler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
start(opts: { onStart: (botInfo: any) => void }) {
|
||||||
|
opts.onStart({ username: 'andy_ai_bot', id: 12345 });
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { TelegramChannel, TelegramChannelOpts } from './telegram.js';
|
||||||
|
|
||||||
|
// --- Test helpers ---
|
||||||
|
|
||||||
|
function createTestOpts(
|
||||||
|
overrides?: Partial<TelegramChannelOpts>,
|
||||||
|
): TelegramChannelOpts {
|
||||||
|
return {
|
||||||
|
onMessage: vi.fn(),
|
||||||
|
onChatMetadata: vi.fn(),
|
||||||
|
registeredGroups: vi.fn(() => ({
|
||||||
|
'tg:100200300': {
|
||||||
|
name: 'Test Group',
|
||||||
|
folder: 'test-group',
|
||||||
|
trigger: '@Andy',
|
||||||
|
added_at: '2024-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTextCtx(overrides: {
|
||||||
|
chatId?: number;
|
||||||
|
chatType?: string;
|
||||||
|
chatTitle?: string;
|
||||||
|
text: string;
|
||||||
|
fromId?: number;
|
||||||
|
firstName?: string;
|
||||||
|
username?: string;
|
||||||
|
messageId?: number;
|
||||||
|
date?: number;
|
||||||
|
entities?: any[];
|
||||||
|
}) {
|
||||||
|
const chatId = overrides.chatId ?? 100200300;
|
||||||
|
const chatType = overrides.chatType ?? 'group';
|
||||||
|
return {
|
||||||
|
chat: {
|
||||||
|
id: chatId,
|
||||||
|
type: chatType,
|
||||||
|
title: overrides.chatTitle ?? 'Test Group',
|
||||||
|
},
|
||||||
|
from: {
|
||||||
|
id: overrides.fromId ?? 99001,
|
||||||
|
first_name: overrides.firstName ?? 'Alice',
|
||||||
|
username: overrides.username ?? 'alice_user',
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
text: overrides.text,
|
||||||
|
date: overrides.date ?? Math.floor(Date.now() / 1000),
|
||||||
|
message_id: overrides.messageId ?? 1,
|
||||||
|
entities: overrides.entities ?? [],
|
||||||
|
},
|
||||||
|
me: { username: 'andy_ai_bot' },
|
||||||
|
reply: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMediaCtx(overrides: {
|
||||||
|
chatId?: number;
|
||||||
|
chatType?: string;
|
||||||
|
fromId?: number;
|
||||||
|
firstName?: string;
|
||||||
|
date?: number;
|
||||||
|
messageId?: number;
|
||||||
|
caption?: string;
|
||||||
|
extra?: Record<string, any>;
|
||||||
|
}) {
|
||||||
|
const chatId = overrides.chatId ?? 100200300;
|
||||||
|
return {
|
||||||
|
chat: {
|
||||||
|
id: chatId,
|
||||||
|
type: overrides.chatType ?? 'group',
|
||||||
|
title: 'Test Group',
|
||||||
|
},
|
||||||
|
from: {
|
||||||
|
id: overrides.fromId ?? 99001,
|
||||||
|
first_name: overrides.firstName ?? 'Alice',
|
||||||
|
username: 'alice_user',
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
date: overrides.date ?? Math.floor(Date.now() / 1000),
|
||||||
|
message_id: overrides.messageId ?? 1,
|
||||||
|
caption: overrides.caption,
|
||||||
|
...(overrides.extra || {}),
|
||||||
|
},
|
||||||
|
me: { username: 'andy_ai_bot' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentBot() {
|
||||||
|
return botRef.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerTextMessage(ctx: ReturnType<typeof createTextCtx>) {
|
||||||
|
const handlers = currentBot().filterHandlers.get('message:text') || [];
|
||||||
|
for (const h of handlers) await h(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerMediaMessage(
|
||||||
|
filter: string,
|
||||||
|
ctx: ReturnType<typeof createMediaCtx>,
|
||||||
|
) {
|
||||||
|
const handlers = currentBot().filterHandlers.get(filter) || [];
|
||||||
|
for (const h of handlers) await h(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tests ---
|
||||||
|
|
||||||
|
describe('TelegramChannel', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Connection lifecycle ---
|
||||||
|
|
||||||
|
describe('connection lifecycle', () => {
|
||||||
|
it('resolves connect() when bot starts', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
expect(channel.isConnected()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers command and message handlers on connect', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
expect(currentBot().commandHandlers.has('chatid')).toBe(true);
|
||||||
|
expect(currentBot().commandHandlers.has('ping')).toBe(true);
|
||||||
|
expect(currentBot().filterHandlers.has('message:text')).toBe(true);
|
||||||
|
expect(currentBot().filterHandlers.has('message:photo')).toBe(true);
|
||||||
|
expect(currentBot().filterHandlers.has('message:video')).toBe(true);
|
||||||
|
expect(currentBot().filterHandlers.has('message:voice')).toBe(true);
|
||||||
|
expect(currentBot().filterHandlers.has('message:audio')).toBe(true);
|
||||||
|
expect(currentBot().filterHandlers.has('message:document')).toBe(true);
|
||||||
|
expect(currentBot().filterHandlers.has('message:sticker')).toBe(true);
|
||||||
|
expect(currentBot().filterHandlers.has('message:location')).toBe(true);
|
||||||
|
expect(currentBot().filterHandlers.has('message:contact')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers error handler on connect', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
expect(currentBot().errorHandler).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disconnects cleanly', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
|
||||||
|
await channel.connect();
|
||||||
|
expect(channel.isConnected()).toBe(true);
|
||||||
|
|
||||||
|
await channel.disconnect();
|
||||||
|
expect(channel.isConnected()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isConnected() returns false before connect', () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
|
||||||
|
expect(channel.isConnected()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Text message handling ---
|
||||||
|
|
||||||
|
describe('text message handling', () => {
|
||||||
|
it('delivers message for registered group', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
const ctx = createTextCtx({ text: 'Hello everyone' });
|
||||||
|
await triggerTextMessage(ctx);
|
||||||
|
|
||||||
|
expect(opts.onChatMetadata).toHaveBeenCalledWith(
|
||||||
|
'tg:100200300',
|
||||||
|
expect.any(String),
|
||||||
|
'Test Group',
|
||||||
|
'telegram',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||||
|
'tg:100200300',
|
||||||
|
expect.objectContaining({
|
||||||
|
id: '1',
|
||||||
|
chat_jid: 'tg:100200300',
|
||||||
|
sender: '99001',
|
||||||
|
sender_name: 'Alice',
|
||||||
|
content: 'Hello everyone',
|
||||||
|
is_from_me: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only emits metadata for unregistered chats', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
const ctx = createTextCtx({ chatId: 999999, text: 'Unknown chat' });
|
||||||
|
await triggerTextMessage(ctx);
|
||||||
|
|
||||||
|
expect(opts.onChatMetadata).toHaveBeenCalledWith(
|
||||||
|
'tg:999999',
|
||||||
|
expect.any(String),
|
||||||
|
'Test Group',
|
||||||
|
'telegram',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(opts.onMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips command messages (starting with /)', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
const ctx = createTextCtx({ text: '/start' });
|
||||||
|
await triggerTextMessage(ctx);
|
||||||
|
|
||||||
|
expect(opts.onMessage).not.toHaveBeenCalled();
|
||||||
|
expect(opts.onChatMetadata).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts sender name from first_name', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
const ctx = createTextCtx({ text: 'Hi', firstName: 'Bob' });
|
||||||
|
await triggerTextMessage(ctx);
|
||||||
|
|
||||||
|
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||||
|
'tg:100200300',
|
||||||
|
expect.objectContaining({ sender_name: 'Bob' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to username when first_name missing', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
const ctx = createTextCtx({ text: 'Hi' });
|
||||||
|
ctx.from.first_name = undefined as any;
|
||||||
|
await triggerTextMessage(ctx);
|
||||||
|
|
||||||
|
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||||
|
'tg:100200300',
|
||||||
|
expect.objectContaining({ sender_name: 'alice_user' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to user ID when name and username missing', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
const ctx = createTextCtx({ text: 'Hi', fromId: 42 });
|
||||||
|
ctx.from.first_name = undefined as any;
|
||||||
|
ctx.from.username = undefined as any;
|
||||||
|
await triggerTextMessage(ctx);
|
||||||
|
|
||||||
|
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||||
|
'tg:100200300',
|
||||||
|
expect.objectContaining({ sender_name: '42' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses sender name as chat name for private chats', async () => {
|
||||||
|
const opts = createTestOpts({
|
||||||
|
registeredGroups: vi.fn(() => ({
|
||||||
|
'tg:100200300': {
|
||||||
|
name: 'Private',
|
||||||
|
folder: 'private',
|
||||||
|
trigger: '@Andy',
|
||||||
|
added_at: '2024-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
const ctx = createTextCtx({
|
||||||
|
text: 'Hello',
|
||||||
|
chatType: 'private',
|
||||||
|
firstName: 'Alice',
|
||||||
|
});
|
||||||
|
await triggerTextMessage(ctx);
|
||||||
|
|
||||||
|
expect(opts.onChatMetadata).toHaveBeenCalledWith(
|
||||||
|
'tg:100200300',
|
||||||
|
expect.any(String),
|
||||||
|
'Alice', // Private chats use sender name
|
||||||
|
'telegram',
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses chat title as name for group chats', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
const ctx = createTextCtx({
|
||||||
|
text: 'Hello',
|
||||||
|
chatType: 'supergroup',
|
||||||
|
chatTitle: 'Project Team',
|
||||||
|
});
|
||||||
|
await triggerTextMessage(ctx);
|
||||||
|
|
||||||
|
expect(opts.onChatMetadata).toHaveBeenCalledWith(
|
||||||
|
'tg:100200300',
|
||||||
|
expect.any(String),
|
||||||
|
'Project Team',
|
||||||
|
'telegram',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts message.date to ISO timestamp', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
const unixTime = 1704067200; // 2024-01-01T00:00:00.000Z
|
||||||
|
const ctx = createTextCtx({ text: 'Hello', date: unixTime });
|
||||||
|
await triggerTextMessage(ctx);
|
||||||
|
|
||||||
|
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||||
|
'tg:100200300',
|
||||||
|
expect.objectContaining({
|
||||||
|
timestamp: '2024-01-01T00:00:00.000Z',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- @mention translation ---
|
||||||
|
|
||||||
|
describe('@mention translation', () => {
|
||||||
|
it('translates @bot_username mention to trigger format', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
const ctx = createTextCtx({
|
||||||
|
text: '@andy_ai_bot what time is it?',
|
||||||
|
entities: [{ type: 'mention', offset: 0, length: 12 }],
|
||||||
|
});
|
||||||
|
await triggerTextMessage(ctx);
|
||||||
|
|
||||||
|
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||||
|
'tg:100200300',
|
||||||
|
expect.objectContaining({
|
||||||
|
content: '@Andy @andy_ai_bot what time is it?',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not translate if message already matches trigger', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
const ctx = createTextCtx({
|
||||||
|
text: '@Andy @andy_ai_bot hello',
|
||||||
|
entities: [{ type: 'mention', offset: 6, length: 12 }],
|
||||||
|
});
|
||||||
|
await triggerTextMessage(ctx);
|
||||||
|
|
||||||
|
// Should NOT double-prepend — already starts with @Andy
|
||||||
|
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||||
|
'tg:100200300',
|
||||||
|
expect.objectContaining({
|
||||||
|
content: '@Andy @andy_ai_bot hello',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not translate mentions of other bots', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
const ctx = createTextCtx({
|
||||||
|
text: '@some_other_bot hi',
|
||||||
|
entities: [{ type: 'mention', offset: 0, length: 15 }],
|
||||||
|
});
|
||||||
|
await triggerTextMessage(ctx);
|
||||||
|
|
||||||
|
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||||
|
'tg:100200300',
|
||||||
|
expect.objectContaining({
|
||||||
|
content: '@some_other_bot hi', // No translation
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles mention in middle of message', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
const ctx = createTextCtx({
|
||||||
|
text: 'hey @andy_ai_bot check this',
|
||||||
|
entities: [{ type: 'mention', offset: 4, length: 12 }],
|
||||||
|
});
|
||||||
|
await triggerTextMessage(ctx);
|
||||||
|
|
||||||
|
// Bot is mentioned, message doesn't match trigger → prepend trigger
|
||||||
|
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||||
|
'tg:100200300',
|
||||||
|
expect.objectContaining({
|
||||||
|
content: '@Andy hey @andy_ai_bot check this',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles message with no entities', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
const ctx = createTextCtx({ text: 'plain message' });
|
||||||
|
await triggerTextMessage(ctx);
|
||||||
|
|
||||||
|
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||||
|
'tg:100200300',
|
||||||
|
expect.objectContaining({
|
||||||
|
content: 'plain message',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores non-mention entities', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
const ctx = createTextCtx({
|
||||||
|
text: 'check https://example.com',
|
||||||
|
entities: [{ type: 'url', offset: 6, length: 19 }],
|
||||||
|
});
|
||||||
|
await triggerTextMessage(ctx);
|
||||||
|
|
||||||
|
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||||
|
'tg:100200300',
|
||||||
|
expect.objectContaining({
|
||||||
|
content: 'check https://example.com',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Non-text messages ---
|
||||||
|
|
||||||
|
describe('non-text messages', () => {
|
||||||
|
it('stores photo with placeholder', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
const ctx = createMediaCtx({});
|
||||||
|
await triggerMediaMessage('message:photo', ctx);
|
||||||
|
|
||||||
|
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||||
|
'tg:100200300',
|
||||||
|
expect.objectContaining({ content: '[Photo]' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores photo with caption', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
const ctx = createMediaCtx({ caption: 'Look at this' });
|
||||||
|
await triggerMediaMessage('message:photo', ctx);
|
||||||
|
|
||||||
|
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||||
|
'tg:100200300',
|
||||||
|
expect.objectContaining({ content: '[Photo] Look at this' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores video with placeholder', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
const ctx = createMediaCtx({});
|
||||||
|
await triggerMediaMessage('message:video', ctx);
|
||||||
|
|
||||||
|
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||||
|
'tg:100200300',
|
||||||
|
expect.objectContaining({ content: '[Video]' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores voice message with placeholder', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
const ctx = createMediaCtx({});
|
||||||
|
await triggerMediaMessage('message:voice', ctx);
|
||||||
|
|
||||||
|
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||||
|
'tg:100200300',
|
||||||
|
expect.objectContaining({ content: '[Voice message]' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores audio with placeholder', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
const ctx = createMediaCtx({});
|
||||||
|
await triggerMediaMessage('message:audio', ctx);
|
||||||
|
|
||||||
|
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||||
|
'tg:100200300',
|
||||||
|
expect.objectContaining({ content: '[Audio]' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores document with filename', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
const ctx = createMediaCtx({
|
||||||
|
extra: { document: { file_name: 'report.pdf' } },
|
||||||
|
});
|
||||||
|
await triggerMediaMessage('message:document', ctx);
|
||||||
|
|
||||||
|
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||||
|
'tg:100200300',
|
||||||
|
expect.objectContaining({ content: '[Document: report.pdf]' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores document with fallback name when filename missing', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
const ctx = createMediaCtx({ extra: { document: {} } });
|
||||||
|
await triggerMediaMessage('message:document', ctx);
|
||||||
|
|
||||||
|
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||||
|
'tg:100200300',
|
||||||
|
expect.objectContaining({ content: '[Document: file]' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores sticker with emoji', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
const ctx = createMediaCtx({
|
||||||
|
extra: { sticker: { emoji: '😂' } },
|
||||||
|
});
|
||||||
|
await triggerMediaMessage('message:sticker', ctx);
|
||||||
|
|
||||||
|
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||||
|
'tg:100200300',
|
||||||
|
expect.objectContaining({ content: '[Sticker 😂]' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores location with placeholder', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
const ctx = createMediaCtx({});
|
||||||
|
await triggerMediaMessage('message:location', ctx);
|
||||||
|
|
||||||
|
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||||
|
'tg:100200300',
|
||||||
|
expect.objectContaining({ content: '[Location]' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores contact with placeholder', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
const ctx = createMediaCtx({});
|
||||||
|
await triggerMediaMessage('message:contact', ctx);
|
||||||
|
|
||||||
|
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||||
|
'tg:100200300',
|
||||||
|
expect.objectContaining({ content: '[Contact]' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores non-text messages from unregistered chats', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
const ctx = createMediaCtx({ chatId: 999999 });
|
||||||
|
await triggerMediaMessage('message:photo', ctx);
|
||||||
|
|
||||||
|
expect(opts.onMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- sendMessage ---
|
||||||
|
|
||||||
|
describe('sendMessage', () => {
|
||||||
|
it('sends message via bot API', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
await channel.sendMessage('tg:100200300', 'Hello');
|
||||||
|
|
||||||
|
expect(currentBot().api.sendMessage).toHaveBeenCalledWith(
|
||||||
|
'100200300',
|
||||||
|
'Hello',
|
||||||
|
{ parse_mode: 'Markdown' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips tg: prefix from JID', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
await channel.sendMessage('tg:-1001234567890', 'Group message');
|
||||||
|
|
||||||
|
expect(currentBot().api.sendMessage).toHaveBeenCalledWith(
|
||||||
|
'-1001234567890',
|
||||||
|
'Group message',
|
||||||
|
{ parse_mode: 'Markdown' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('splits messages exceeding 4096 characters', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
const longText = 'x'.repeat(5000);
|
||||||
|
await channel.sendMessage('tg:100200300', longText);
|
||||||
|
|
||||||
|
expect(currentBot().api.sendMessage).toHaveBeenCalledTimes(2);
|
||||||
|
expect(currentBot().api.sendMessage).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
'100200300',
|
||||||
|
'x'.repeat(4096),
|
||||||
|
{ parse_mode: 'Markdown' },
|
||||||
|
);
|
||||||
|
expect(currentBot().api.sendMessage).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
'100200300',
|
||||||
|
'x'.repeat(904),
|
||||||
|
{ parse_mode: 'Markdown' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends exactly one message at 4096 characters', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
const exactText = 'y'.repeat(4096);
|
||||||
|
await channel.sendMessage('tg:100200300', exactText);
|
||||||
|
|
||||||
|
expect(currentBot().api.sendMessage).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles send failure gracefully', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
currentBot().api.sendMessage.mockRejectedValueOnce(
|
||||||
|
new Error('Network error'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
await expect(
|
||||||
|
channel.sendMessage('tg:100200300', 'Will fail'),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when bot is not initialized', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
|
||||||
|
// Don't connect — bot is null
|
||||||
|
await channel.sendMessage('tg:100200300', 'No bot');
|
||||||
|
|
||||||
|
// No error, no API call
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- ownsJid ---
|
||||||
|
|
||||||
|
describe('ownsJid', () => {
|
||||||
|
it('owns tg: JIDs', () => {
|
||||||
|
const channel = new TelegramChannel('test-token', createTestOpts());
|
||||||
|
expect(channel.ownsJid('tg:123456')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('owns tg: JIDs with negative IDs (groups)', () => {
|
||||||
|
const channel = new TelegramChannel('test-token', createTestOpts());
|
||||||
|
expect(channel.ownsJid('tg:-1001234567890')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not own WhatsApp group JIDs', () => {
|
||||||
|
const channel = new TelegramChannel('test-token', createTestOpts());
|
||||||
|
expect(channel.ownsJid('12345@g.us')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not own WhatsApp DM JIDs', () => {
|
||||||
|
const channel = new TelegramChannel('test-token', createTestOpts());
|
||||||
|
expect(channel.ownsJid('12345@s.whatsapp.net')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not own unknown JID formats', () => {
|
||||||
|
const channel = new TelegramChannel('test-token', createTestOpts());
|
||||||
|
expect(channel.ownsJid('random-string')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- setTyping ---
|
||||||
|
|
||||||
|
describe('setTyping', () => {
|
||||||
|
it('sends typing action when isTyping is true', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
await channel.setTyping('tg:100200300', true);
|
||||||
|
|
||||||
|
expect(currentBot().api.sendChatAction).toHaveBeenCalledWith(
|
||||||
|
'100200300',
|
||||||
|
'typing',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when isTyping is false', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
await channel.setTyping('tg:100200300', false);
|
||||||
|
|
||||||
|
expect(currentBot().api.sendChatAction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when bot is not initialized', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
|
||||||
|
// Don't connect
|
||||||
|
await channel.setTyping('tg:100200300', true);
|
||||||
|
|
||||||
|
// No error, no API call
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles typing indicator failure gracefully', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
currentBot().api.sendChatAction.mockRejectedValueOnce(
|
||||||
|
new Error('Rate limited'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
channel.setTyping('tg:100200300', true),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Bot commands ---
|
||||||
|
|
||||||
|
describe('bot commands', () => {
|
||||||
|
it('/chatid replies with chat ID and metadata', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
const handler = currentBot().commandHandlers.get('chatid')!;
|
||||||
|
const ctx = {
|
||||||
|
chat: { id: 100200300, type: 'group' as const },
|
||||||
|
from: { first_name: 'Alice' },
|
||||||
|
reply: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await handler(ctx);
|
||||||
|
|
||||||
|
expect(ctx.reply).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('tg:100200300'),
|
||||||
|
expect.objectContaining({ parse_mode: 'Markdown' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/chatid shows chat type', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
const handler = currentBot().commandHandlers.get('chatid')!;
|
||||||
|
const ctx = {
|
||||||
|
chat: { id: 555, type: 'private' as const },
|
||||||
|
from: { first_name: 'Bob' },
|
||||||
|
reply: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await handler(ctx);
|
||||||
|
|
||||||
|
expect(ctx.reply).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('private'),
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/ping replies with bot status', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new TelegramChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
const handler = currentBot().commandHandlers.get('ping')!;
|
||||||
|
const ctx = { reply: vi.fn() };
|
||||||
|
|
||||||
|
await handler(ctx);
|
||||||
|
|
||||||
|
expect(ctx.reply).toHaveBeenCalledWith('Andy is online.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Channel properties ---
|
||||||
|
|
||||||
|
describe('channel properties', () => {
|
||||||
|
it('has name "telegram"', () => {
|
||||||
|
const channel = new TelegramChannel('test-token', createTestOpts());
|
||||||
|
expect(channel.name).toBe('telegram');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
298
src/channels/telegram.ts
Normal file
298
src/channels/telegram.ts
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
import https from 'https';
|
||||||
|
import { Api, Bot } from 'grammy';
|
||||||
|
|
||||||
|
import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js';
|
||||||
|
import { readEnvFile } from '../env.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
import { registerChannel, ChannelOpts } from './registry.js';
|
||||||
|
import {
|
||||||
|
Channel,
|
||||||
|
OnChatMetadata,
|
||||||
|
OnInboundMessage,
|
||||||
|
RegisteredGroup,
|
||||||
|
} from '../types.js';
|
||||||
|
|
||||||
|
export interface TelegramChannelOpts {
|
||||||
|
onMessage: OnInboundMessage;
|
||||||
|
onChatMetadata: OnChatMetadata;
|
||||||
|
registeredGroups: () => Record<string, RegisteredGroup>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message with Telegram Markdown parse mode, falling back to plain text.
|
||||||
|
* Claude's output naturally matches Telegram's Markdown v1 format:
|
||||||
|
* *bold*, _italic_, `code`, ```code blocks```, [links](url)
|
||||||
|
*/
|
||||||
|
async function sendTelegramMessage(
|
||||||
|
api: { sendMessage: Api['sendMessage'] },
|
||||||
|
chatId: string | number,
|
||||||
|
text: string,
|
||||||
|
options: { message_thread_id?: number } = {},
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await api.sendMessage(chatId, text, {
|
||||||
|
...options,
|
||||||
|
parse_mode: 'Markdown',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Fallback: send as plain text if Markdown parsing fails
|
||||||
|
logger.debug({ err }, 'Markdown send failed, falling back to plain text');
|
||||||
|
await api.sendMessage(chatId, text, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TelegramChannel implements Channel {
|
||||||
|
name = 'telegram';
|
||||||
|
|
||||||
|
private bot: Bot | null = null;
|
||||||
|
private opts: TelegramChannelOpts;
|
||||||
|
private botToken: string;
|
||||||
|
|
||||||
|
constructor(botToken: string, opts: TelegramChannelOpts) {
|
||||||
|
this.botToken = botToken;
|
||||||
|
this.opts = opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
this.bot = new Bot(this.botToken, {
|
||||||
|
client: {
|
||||||
|
baseFetchConfig: { agent: https.globalAgent, compress: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Command to get chat ID (useful for registration)
|
||||||
|
this.bot.command('chatid', (ctx) => {
|
||||||
|
const chatId = ctx.chat.id;
|
||||||
|
const chatType = ctx.chat.type;
|
||||||
|
const chatName =
|
||||||
|
chatType === 'private'
|
||||||
|
? ctx.from?.first_name || 'Private'
|
||||||
|
: (ctx.chat as any).title || 'Unknown';
|
||||||
|
|
||||||
|
ctx.reply(
|
||||||
|
`Chat ID: \`tg:${chatId}\`\nName: ${chatName}\nType: ${chatType}`,
|
||||||
|
{ parse_mode: 'Markdown' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Command to check bot status
|
||||||
|
this.bot.command('ping', (ctx) => {
|
||||||
|
ctx.reply(`${ASSISTANT_NAME} is online.`);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.bot.on('message:text', async (ctx) => {
|
||||||
|
// Skip commands
|
||||||
|
if (ctx.message.text.startsWith('/')) return;
|
||||||
|
|
||||||
|
const chatJid = `tg:${ctx.chat.id}`;
|
||||||
|
let content = ctx.message.text;
|
||||||
|
const timestamp = new Date(ctx.message.date * 1000).toISOString();
|
||||||
|
const senderName =
|
||||||
|
ctx.from?.first_name ||
|
||||||
|
ctx.from?.username ||
|
||||||
|
ctx.from?.id.toString() ||
|
||||||
|
'Unknown';
|
||||||
|
const sender = ctx.from?.id.toString() || '';
|
||||||
|
const msgId = ctx.message.message_id.toString();
|
||||||
|
|
||||||
|
// Determine chat name
|
||||||
|
const chatName =
|
||||||
|
ctx.chat.type === 'private'
|
||||||
|
? senderName
|
||||||
|
: (ctx.chat as any).title || chatJid;
|
||||||
|
|
||||||
|
// Translate Telegram @bot_username mentions into TRIGGER_PATTERN format.
|
||||||
|
// Telegram @mentions (e.g., @andy_ai_bot) won't match TRIGGER_PATTERN
|
||||||
|
// (e.g., ^@Andy\b), so we prepend the trigger when the bot is @mentioned.
|
||||||
|
const botUsername = ctx.me?.username?.toLowerCase();
|
||||||
|
if (botUsername) {
|
||||||
|
const entities = ctx.message.entities || [];
|
||||||
|
const isBotMentioned = entities.some((entity) => {
|
||||||
|
if (entity.type === 'mention') {
|
||||||
|
const mentionText = content
|
||||||
|
.substring(entity.offset, entity.offset + entity.length)
|
||||||
|
.toLowerCase();
|
||||||
|
return mentionText === `@${botUsername}`;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
if (isBotMentioned && !TRIGGER_PATTERN.test(content)) {
|
||||||
|
content = `@${ASSISTANT_NAME} ${content}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store chat metadata for discovery
|
||||||
|
const isGroup =
|
||||||
|
ctx.chat.type === 'group' || ctx.chat.type === 'supergroup';
|
||||||
|
this.opts.onChatMetadata(
|
||||||
|
chatJid,
|
||||||
|
timestamp,
|
||||||
|
chatName,
|
||||||
|
'telegram',
|
||||||
|
isGroup,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only deliver full message for registered groups
|
||||||
|
const group = this.opts.registeredGroups()[chatJid];
|
||||||
|
if (!group) {
|
||||||
|
logger.debug(
|
||||||
|
{ chatJid, chatName },
|
||||||
|
'Message from unregistered Telegram chat',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliver message — startMessageLoop() will pick it up
|
||||||
|
this.opts.onMessage(chatJid, {
|
||||||
|
id: msgId,
|
||||||
|
chat_jid: chatJid,
|
||||||
|
sender,
|
||||||
|
sender_name: senderName,
|
||||||
|
content,
|
||||||
|
timestamp,
|
||||||
|
is_from_me: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{ chatJid, chatName, sender: senderName },
|
||||||
|
'Telegram message stored',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle non-text messages with placeholders so the agent knows something was sent
|
||||||
|
const storeNonText = (ctx: any, placeholder: string) => {
|
||||||
|
const chatJid = `tg:${ctx.chat.id}`;
|
||||||
|
const group = this.opts.registeredGroups()[chatJid];
|
||||||
|
if (!group) return;
|
||||||
|
|
||||||
|
const timestamp = new Date(ctx.message.date * 1000).toISOString();
|
||||||
|
const senderName =
|
||||||
|
ctx.from?.first_name ||
|
||||||
|
ctx.from?.username ||
|
||||||
|
ctx.from?.id?.toString() ||
|
||||||
|
'Unknown';
|
||||||
|
const caption = ctx.message.caption ? ` ${ctx.message.caption}` : '';
|
||||||
|
|
||||||
|
const isGroup =
|
||||||
|
ctx.chat.type === 'group' || ctx.chat.type === 'supergroup';
|
||||||
|
this.opts.onChatMetadata(
|
||||||
|
chatJid,
|
||||||
|
timestamp,
|
||||||
|
undefined,
|
||||||
|
'telegram',
|
||||||
|
isGroup,
|
||||||
|
);
|
||||||
|
this.opts.onMessage(chatJid, {
|
||||||
|
id: ctx.message.message_id.toString(),
|
||||||
|
chat_jid: chatJid,
|
||||||
|
sender: ctx.from?.id?.toString() || '',
|
||||||
|
sender_name: senderName,
|
||||||
|
content: `${placeholder}${caption}`,
|
||||||
|
timestamp,
|
||||||
|
is_from_me: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
this.bot.on('message:photo', (ctx) => storeNonText(ctx, '[Photo]'));
|
||||||
|
this.bot.on('message:video', (ctx) => storeNonText(ctx, '[Video]'));
|
||||||
|
this.bot.on('message:voice', (ctx) => storeNonText(ctx, '[Voice message]'));
|
||||||
|
this.bot.on('message:audio', (ctx) => storeNonText(ctx, '[Audio]'));
|
||||||
|
this.bot.on('message:document', (ctx) => {
|
||||||
|
const name = ctx.message.document?.file_name || 'file';
|
||||||
|
storeNonText(ctx, `[Document: ${name}]`);
|
||||||
|
});
|
||||||
|
this.bot.on('message:sticker', (ctx) => {
|
||||||
|
const emoji = ctx.message.sticker?.emoji || '';
|
||||||
|
storeNonText(ctx, `[Sticker ${emoji}]`);
|
||||||
|
});
|
||||||
|
this.bot.on('message:location', (ctx) => storeNonText(ctx, '[Location]'));
|
||||||
|
this.bot.on('message:contact', (ctx) => storeNonText(ctx, '[Contact]'));
|
||||||
|
|
||||||
|
// Handle errors gracefully
|
||||||
|
this.bot.catch((err) => {
|
||||||
|
logger.error({ err: err.message }, 'Telegram bot error');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start polling — returns a Promise that resolves when started
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
this.bot!.start({
|
||||||
|
onStart: (botInfo) => {
|
||||||
|
logger.info(
|
||||||
|
{ username: botInfo.username, id: botInfo.id },
|
||||||
|
'Telegram bot connected',
|
||||||
|
);
|
||||||
|
console.log(`\n Telegram bot: @${botInfo.username}`);
|
||||||
|
console.log(
|
||||||
|
` Send /chatid to the bot to get a chat's registration ID\n`,
|
||||||
|
);
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(jid: string, text: string): Promise<void> {
|
||||||
|
if (!this.bot) {
|
||||||
|
logger.warn('Telegram bot not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const numericId = jid.replace(/^tg:/, '');
|
||||||
|
|
||||||
|
// Telegram has a 4096 character limit per message — split if needed
|
||||||
|
const MAX_LENGTH = 4096;
|
||||||
|
if (text.length <= MAX_LENGTH) {
|
||||||
|
await sendTelegramMessage(this.bot.api, numericId, text);
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < text.length; i += MAX_LENGTH) {
|
||||||
|
await sendTelegramMessage(
|
||||||
|
this.bot.api,
|
||||||
|
numericId,
|
||||||
|
text.slice(i, i + MAX_LENGTH),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.info({ jid, length: text.length }, 'Telegram message sent');
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ jid, err }, 'Failed to send Telegram message');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnected(): boolean {
|
||||||
|
return this.bot !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
ownsJid(jid: string): boolean {
|
||||||
|
return jid.startsWith('tg:');
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
if (this.bot) {
|
||||||
|
this.bot.stop();
|
||||||
|
this.bot = null;
|
||||||
|
logger.info('Telegram bot stopped');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setTyping(jid: string, isTyping: boolean): Promise<void> {
|
||||||
|
if (!this.bot || !isTyping) return;
|
||||||
|
try {
|
||||||
|
const numericId = jid.replace(/^tg:/, '');
|
||||||
|
await this.bot.api.sendChatAction(numericId, 'typing');
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug({ jid, err }, 'Failed to send Telegram typing indicator');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerChannel('telegram', (opts: ChannelOpts) => {
|
||||||
|
const envVars = readEnvFile(['TELEGRAM_BOT_TOKEN']);
|
||||||
|
const token =
|
||||||
|
process.env.TELEGRAM_BOT_TOKEN || envVars.TELEGRAM_BOT_TOKEN || '';
|
||||||
|
if (!token) {
|
||||||
|
logger.warn('Telegram: TELEGRAM_BOT_TOKEN not set');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new TelegramChannel(token, opts);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user