Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.7.0
0.7.1
3 changes: 2 additions & 1 deletion cmd/localforge/config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ agent:
# Set WEBHOOK_SECRET_TELEGRAM in .env before start_ngrok/set_webhook; Localforge requires it for Telegram webhooks.
# In Telegram, send /new_conversation to start a fresh JSON thread (map stored under data/telegram_thread_map.json).
# Telegram access control: comma-separated chat/user IDs allowed to talk to the agent.
# Leave unset (or empty) to allow all senders. Set TELEGRAM_ALLOWED_CHAT_IDS in your .env.
# Leave unset (or empty) to allow all senders. TELEGRAM_ALLOWED_USER_IDS: comma-separated Telegram
# usernames (e.g. @yourname,@friend). Leading @ is optional; matching is case-insensitive.
# To find your chat ID: send any message to the bot and check the Localforge debug logs.
# Optional: ephemeral sub-tasks with spawn_subagent (subset of parent tools):
# spawn_subagent: true
Expand Down
16 changes: 13 additions & 3 deletions cmd/localforge/src/handlers_webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,13 @@ func (s *Server) handleWebhook(c *gin.Context) {
return
}

if ap, ok := providerInst.(AllowlistProvider); ok && !ap.IsAllowed(recipientID) {
agentforge.Debug("Blocked webhook from %s (chat ID %s not in allowlist)", provider, recipientID)
if tp, ok := providerInst.(*providers.TelegramProvider); ok {
if !tp.AllowlistPermits(payload) {
agentforge.Debug("Blocked webhook from telegram: set TELEGRAM_ALLOWED_USER_IDS or sender username not in allowlist")
return
}
} else if ap, ok := providerInst.(AllowlistProvider); ok && !ap.IsAllowed(recipientID) {
agentforge.Debug("Blocked webhook from %s (recipient %s not in allowlist)", provider, recipientID)
return
}

Expand Down Expand Up @@ -635,7 +640,12 @@ func (s *Server) handleWebhookSync(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "could not extract recipient"})
return
}
if ap, ok := providerInst.(AllowlistProvider); ok && !ap.IsAllowed(recipientID) {
if tp, ok := providerInst.(*providers.TelegramProvider); ok {
if !tp.AllowlistPermits(payload) {
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
return
}
} else if ap, ok := providerInst.(AllowlistProvider); ok && !ap.IsAllowed(recipientID) {
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
return
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/localforge/src/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ func main() {

// After each heartbeat turn, send the full response to all known Telegram recipients.
// Recipients are discovered from conversation files and the telegram thread store —
// no TELEGRAM_ALLOWED_CHAT_IDS env var required.
// no TELEGRAM_ALLOWED_USER_IDS env var required.
agentMgr.SetTurnCompleteRouter(func(chatId, fullContent string) {
if !strings.HasPrefix(chatId, "heartbeat-") {
return
Expand Down
97 changes: 67 additions & 30 deletions cmd/localforge/src/providers/telegram.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,44 +12,90 @@ import (

// TelegramProvider implements the Provider interface for Telegram messaging
type TelegramProvider struct {
botToken string
client *http.Client
allowedChatIDs map[string]struct{} // empty = allow all
botToken string
client *http.Client
allowedUsernames map[string]struct{} // lowercase, no @; empty = deny all (TELEGRAM_ALLOWED_USER_IDS required)
}

// NewTelegramProvider creates a new Telegram provider.
// allowedChatIDs is an optional set of Telegram chat/user IDs that are
// permitted to interact with the agent. Pass nil or an empty slice to allow
// all senders (default / backward-compatible behaviour).
func NewTelegramProvider(botToken string, allowedChatIDs []string) *TelegramProvider {
allowed := make(map[string]struct{}, len(allowedChatIDs))
for _, id := range allowedChatIDs {
if id != "" {
allowed[id] = struct{}{}
// allowlistEntries (TELEGRAM_ALLOWED_USER_IDS) must contain at least one Telegram username
// (with or without @), matched case-insensitively against message.from.username.
// An empty list blocks all incoming webhooks.
func NewTelegramProvider(botToken string, allowlistEntries []string) *TelegramProvider {
allowed := make(map[string]struct{}, len(allowlistEntries))
for _, raw := range allowlistEntries {
u := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(raw), "@")))
if u != "" {
allowed[u] = struct{}{}
}
}
return &TelegramProvider{
botToken: botToken,
client: &http.Client{Timeout: 30 * time.Second},
allowedChatIDs: allowed,
botToken: botToken,
client: &http.Client{Timeout: 30 * time.Second},
allowedUsernames: allowed,
}
}

// IsAllowed returns true when the given chat/user ID is in the allowlist, or
// when no allowlist is configured (len == 0 means allow all).
func (p *TelegramProvider) IsAllowed(chatID string) bool {
if len(p.allowedChatIDs) == 0 {
return true
// AllowlistPermits returns true when the sender's username (message.from.username /
// callback_query.from.username) is listed in TELEGRAM_ALLOWED_USER_IDS.
// Matching is case-insensitive; leading @ is ignored.
// Returns false when the allowlist is empty (TELEGRAM_ALLOWED_USER_IDS not set).
func (p *TelegramProvider) AllowlistPermits(payload map[string]interface{}) bool {
if len(p.allowedUsernames) == 0 {
return false
}
from, ok := telegramFromPayload(payload)
if !ok {
return false
}
username, _ := from["username"].(string)
if username == "" {
return false
}
_, ok := p.allowedChatIDs[chatID]
_, ok = p.allowedUsernames[strings.ToLower(username)]
return ok
}

func telegramFromPayload(payload map[string]interface{}) (map[string]interface{}, bool) {
if cbq, ok := payload["callback_query"].(map[string]interface{}); ok {
if from, ok := cbq["from"].(map[string]interface{}); ok {
return from, true
}
}
var msg map[string]interface{}
if m, ok := payload["message"].(map[string]interface{}); ok {
msg = m
} else if m, ok := payload["edited_message"].(map[string]interface{}); ok {
msg = m
}
if msg == nil {
return nil, false
}
if from, ok := msg["from"].(map[string]interface{}); ok {
return from, true
}
return nil, false
}

// Name returns the provider name
func (p *TelegramProvider) Name() string {
return "telegram"
}

// formatTelegramScalarID formats a user or chat id from JSON-decoded Update objects.
func formatTelegramScalarID(id interface{}) (string, error) {
switch v := id.(type) {
case float64:
return fmt.Sprintf("%.0f", v), nil
case int:
return fmt.Sprintf("%d", v), nil
case string:
return v, nil
default:
return "", fmt.Errorf("invalid id type: %T", id)
}
}

// extractChatID parses a chat.id value from a Telegram message map.
func extractChatID(message map[string]interface{}) (string, error) {
chat, ok := message["chat"].(map[string]interface{})
Expand All @@ -60,16 +106,7 @@ func extractChatID(message map[string]interface{}) (string, error) {
if !ok {
return "", fmt.Errorf("missing chat 'id'")
}
switch v := chatID.(type) {
case float64:
return fmt.Sprintf("%.0f", v), nil
case int:
return fmt.Sprintf("%d", v), nil
case string:
return v, nil
default:
return "", fmt.Errorf("invalid chat id type: %T", chatID)
}
return formatTelegramScalarID(chatID)
}

// ExtractRecipient extracts the chat ID from a Telegram webhook payload.
Expand Down
45 changes: 45 additions & 0 deletions cmd/localforge/src/providers/telegram_command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,48 @@ func TestTelegramMessageText(t *testing.T) {
t.Fatal("expected false for empty payload")
}
}

func TestTelegramAllowlistPermits(t *testing.T) {
tok := "x"
payload := func(username string) map[string]interface{} {
return map[string]interface{}{
"message": map[string]interface{}{
"from": map[string]interface{}{
"id": float64(999),
"username": username,
},
"chat": map[string]interface{}{"id": float64(999), "type": "private"},
"text": "hi",
},
}
}

if NewTelegramProvider(tok, nil).AllowlistPermits(payload("alice")) {
t.Fatal("empty allowlist (TELEGRAM_ALLOWED_USER_IDS not set) should deny all")
}
if !NewTelegramProvider(tok, []string{"alice"}).AllowlistPermits(payload("alice")) {
t.Fatal("exact username match should permit")
}
if !NewTelegramProvider(tok, []string{"@Alice"}).AllowlistPermits(payload("alice")) {
t.Fatal("@ prefix and different case should permit")
}
if NewTelegramProvider(tok, []string{"bob"}).AllowlistPermits(payload("alice")) {
t.Fatal("wrong username should deny")
}
if NewTelegramProvider(tok, []string{"alice"}).AllowlistPermits(payload("")) {
t.Fatal("no username in payload should deny")
}

cbqPayload := map[string]interface{}{
"callback_query": map[string]interface{}{
"id": "cb1",
"from": map[string]interface{}{"id": float64(99), "username": "Alice"},
"message": map[string]interface{}{
"chat": map[string]interface{}{"id": float64(99)},
},
},
}
if !NewTelegramProvider(tok, []string{"alice"}).AllowlistPermits(cbqPayload) {
t.Fatal("callback_query from username should permit")
}
}
2 changes: 1 addition & 1 deletion cmd/localforge/src/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ func NewServer(agentMgr *AgentManager, configMgr *ConfigManager, todoMgr *TodoMa
// Register Telegram provider if token exists
if token := os.Getenv("TELEGRAM_BOT_TOKEN"); token != "" {
var allowedIDs []string
if raw := os.Getenv("TELEGRAM_ALLOWED_CHAT_IDS"); raw != "" {
if raw := os.Getenv("TELEGRAM_ALLOWED_USER_IDS"); raw != "" {
for _, id := range strings.Split(raw, ",") {
allowedIDs = append(allowedIDs, strings.TrimSpace(id))
}
Expand Down
63 changes: 63 additions & 0 deletions scripts/install-release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,43 @@ for a in data.get('assets', []):
esac
}

# Download all files under docs/ from the main branch (raw.githubusercontent.com).
download_docs_from_github() {
local dest="$1"
local api_url="https://api.github.com/repos/${REPO}/git/trees/main?recursive=1"
local tree_json paths
tree_json="$(curl -fsSL "$api_url")" || return 1
case "$JSON_TOOL" in
jq)
paths="$(echo "$tree_json" | jq -r '.tree[] | select(.type == "blob") | select(.path | startswith("docs/")) | .path')"
;;
python3|python)
paths="$(echo "$tree_json" | "$JSON_TOOL" -c "
import sys, json
d = json.load(sys.stdin)
for t in d.get('tree', []):
if t.get('type') == 'blob':
p = t.get('path', '')
if p.startswith('docs/'):
print(p)
")"
;;
esac
if [ -z "$paths" ]; then
return 1
fi
mkdir -p "$dest"
while IFS= read -r path; do
[ -z "$path" ] && continue
local rel="${path#docs/}"
local outfile="$dest/$rel"
mkdir -p "$(dirname "$outfile")"
local raw_url="https://raw.githubusercontent.com/${REPO}/main/${path}"
curl -fsSL "$raw_url" -o "$outfile" || return 1
done <<< "$paths"
return 0
}

# ─── OS / arch detection ─────────────────────────────────────────────────────

RAW_OS="$(uname -s 2>/dev/null || echo "unknown")"
Expand Down Expand Up @@ -179,6 +216,31 @@ else
fi
fi

# ─── docs/ (framework reference; full tree from repo or GitHub main) ─────────

DOCS_DEST="$INSTALL_DIR/docs"
if [ -d "$DOCS_DEST" ] && [ -n "$(ls -A "$DOCS_DEST" 2>/dev/null)" ]; then
echo "docs/ already present — skipping."
else
DOCS_OK=false
if [ -n "${BASH_SOURCE[0]:-}" ]; then
_INSTALL_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ -d "$_INSTALL_SCRIPT_DIR/../docs" ]; then
mkdir -p "$DOCS_DEST"
cp -a "$_INSTALL_SCRIPT_DIR/../docs/." "$DOCS_DEST/"
DOCS_OK=true
echo "docs/ copied into workspace."
fi
fi
if [ "$DOCS_OK" = false ]; then
if download_docs_from_github "$DOCS_DEST"; then
echo "docs/ downloaded from GitHub (main)."
else
echo "WARNING: Could not copy or download docs/ into workspace (skipped)."
fi
fi
fi

# ─── download binary ─────────────────────────────────────────────────────────

echo "Downloading $ASSET_NAME..."
Expand Down Expand Up @@ -305,6 +367,7 @@ echo ""
echo "Install complete ($TAG):"
echo " $BINARY_PATH"
echo " $INSTALL_DIR/README.md — framework capabilities (agent workspace entrypoint)"
echo " $INSTALL_DIR/docs/ — framework reference (from install-release.sh)"
echo " $INSTALL_DIR/config.yaml — edit model, system_prompt, tools"
echo " $INSTALL_DIR/.env — add your API keys"
echo " $UPDATE_SCRIPT — run to update to latest version"
Expand Down
Loading
Loading